md2ui 1.0.18 → 1.0.20
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/README.md +51 -58
- package/bin/build.js +95 -9
- package/bin/md2ui.js +102 -13
- package/package.json +24 -10
- package/public/docs/00-/345/277/253/351/200/237/345/274/200/345/247/213.md +48 -28
- package/public/docs/01-/345/212/237/350/203/275/347/211/271/346/200/247.md +55 -40
- package/public/docs/02-Markdown/346/270/262/346/237/223/00-/345/237/272/347/241/200/350/257/255/346/263/225.md +88 -0
- package/public/docs/02-Markdown/346/270/262/346/237/223/01-/344/273/243/347/240/201/345/235/227.md +91 -0
- package/public/docs/02-Markdown/346/270/262/346/237/223/02-/350/241/250/346/240/274.md +187 -0
- package/public/docs/02-Markdown/346/270/262/346/237/223/03-Mermaid/345/233/276/350/241/250.md +101 -0
- package/public/docs/02-Markdown/346/270/262/346/237/223/04-Frontmatter.md +32 -0
- package/public/docs/02-Markdown/346/270/262/346/237/223/05-/346/225/260/345/255/246/345/205/254/345/274/217.md +47 -0
- package/public/docs/02-Markdown/346/270/262/346/237/223/06-Mermaid/345/244/215/346/235/202/345/233/276/350/241/250/346/265/213/350/257/225.md +1376 -0
- package/public/docs/02-Markdown/346/270/262/346/237/223/assets/img-1777383093712.png +0 -0
- package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/00-/344/270/211/346/240/217/345/270/203/345/261/200.md +33 -0
- package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/01-/347/233/256/345/275/225/346/240/221/345/257/274/350/210/252.md +43 -0
- package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/02-/346/226/207/346/241/243/345/244/247/347/272/262.md +51 -0
- package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/03-/344/270/212/344/270/213/347/257/207/345/257/274/350/210/252.md +29 -0
- package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/04-/347/253/231/345/206/205/351/223/276/346/216/245.md +39 -0
- package/public/docs/03-/345/257/274/350/210/252/344/270/216/345/270/203/345/261/200/05-/345/244/247/347/272/262/345/216/213/345/212/233/346/265/213/350/257/225.md +340 -0
- package/public/docs/04-/346/220/234/347/264/242/345/212/237/350/203/275/00-/345/205/250/346/226/207/346/220/234/347/264/242.md +46 -0
- package/public/docs/05-/347/274/226/350/276/221/345/212/237/350/203/275/00-/347/274/226/350/276/221/345/231/250/345/237/272/347/241/200.md +65 -0
- package/public/docs/05-/347/274/226/350/276/221/345/212/237/350/203/275/01-/350/207/252/345/212/250/344/277/235/345/255/230.md +38 -0
- package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/00-/351/230/205/350/257/273/350/277/233/345/272/246.md +43 -0
- package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/01-/345/233/276/347/211/207/346/224/276/345/244/247.md +40 -0
- package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/02-/350/277/224/345/233/236/351/241/266/351/203/250.md +38 -0
- package/public/docs/06-/351/230/205/350/257/273/344/275/223/351/252/214/assets/img-1777261394722.png +0 -0
- package/public/docs/07-/347/247/273/345/212/250/347/253/257/351/200/202/351/205/215/00-/345/223/215/345/272/224/345/274/217/345/270/203/345/261/200.md +37 -0
- package/public/docs/08-/346/226/207/346/241/243/347/256/241/347/220/206/00-/346/226/260/345/273/272/344/270/216/345/210/240/351/231/244.md +47 -0
- package/public/docs/09-/345/257/274/345/207/272/345/212/237/350/203/275/00-/345/257/274/345/207/272Word.md +77 -0
- package/public/docs/10-/351/203/250/347/275/262/344/270/216/351/205/215/347/275/256/00-CLI/345/267/245/345/205/267.md +52 -0
- package/public/docs/10-/351/203/250/347/275/262/344/270/216/351/205/215/347/275/256/01-SSG/351/235/231/346/200/201/346/236/204/345/273/272.md +44 -0
- package/public/docs/10-/351/203/250/347/275/262/344/270/216/351/205/215/347/275/256/02-/350/207/252/345/256/232/344/271/211/351/205/215/347/275/256.md +58 -0
- package/public/docs/11-/345/244/232/347/272/247/347/233/256/345/275/225/346/265/213/350/257/225/00-/344/270/200/347/272/247/346/226/207/346/241/243.md +20 -0
- package/public/docs/11-/345/244/232/347/272/247/347/233/256/345/275/225/346/265/213/350/257/225/01-/345/255/220/347/233/256/345/275/225/00-/344/272/214/347/272/247/346/226/207/346/241/243.md +13 -0
- package/public/docs/11-/345/244/232/347/272/247/347/233/256/345/275/225/346/265/213/350/257/225/01-/345/255/220/347/233/256/345/275/225/01-/346/267/261/345/261/202/345/265/214/345/245/227/00-/344/270/211/347/272/247/346/226/207/346/241/243.md +23 -0
- package/src/App.vue +111 -12
- package/src/components/AppSidebar.vue +181 -21
- package/src/components/CodeBlockNodeView.vue +72 -0
- package/src/components/DocContent.vue +25 -14
- package/src/components/EditorContent.vue +257 -0
- package/src/components/EditorToolbar.vue +264 -0
- package/src/components/ImageZoom.vue +88 -5
- package/src/components/MathBlockNodeView.vue +160 -0
- package/src/components/MathInlineNodeView.vue +145 -0
- package/src/components/MermaidNodeView.vue +157 -0
- package/src/components/MobileSearch.vue +97 -0
- package/src/components/TableOfContents.vue +174 -32
- package/src/components/TopBar.vue +69 -4
- package/src/components/TreeNode.vue +232 -39
- package/src/components/WelcomePage.vue +2 -2
- package/src/composables/useDocHash.js +9 -1
- package/src/composables/useDocManager.js +452 -105
- package/src/composables/useDocTree.js +33 -2
- package/src/composables/useExportWord.js +73 -10
- package/src/composables/useFileWatcher.js +45 -0
- package/src/composables/useFrontmatter.js +2 -2
- package/src/composables/useMarkdown.js +450 -52
- package/src/composables/useMermaidCache.js +15 -0
- package/src/composables/useScroll.js +354 -27
- package/src/composables/useSearch.js +12 -11
- package/src/config.js +1 -4
- package/src/extensions/CodeBlockCustom.js +113 -0
- package/src/extensions/MathBlock.js +107 -0
- package/src/extensions/MathInline.js +100 -0
- package/src/extensions/MermaidBlock.js +73 -0
- package/src/extensions/TableControls.js +670 -0
- package/src/services/DocService.js +168 -0
- package/src/style.css +2416 -36
- package/src/utils/imageConverter.js +129 -0
- package/vite-plugin-doc-api.js +369 -0
- package/vite.config.js +7 -2
- package/public/docs/02-Mermaid/345/233/276/350/241/250.md +0 -102
- package/public/docs/03-/350/277/233/351/230/266/346/214/207/345/215/227/01-/347/233/256/345/275/225/347/273/223/346/236/204.md +0 -55
- package/public/docs/03-/350/277/233/351/230/266/346/214/207/345/215/227/02-/350/207/252/345/256/232/344/271/211/351/205/215/347/275/256.md +0 -63
- package/public/docs/03-/350/277/233/351/230/266/346/214/207/345/215/227/03-/351/203/250/347/275/262/346/226/271/346/241/210.md +0 -73
- package/public/docs/04-API/345/217/202/350/200/203/01-/347/273/204/344/273/266API.md +0 -80
- package/public/docs/04-API/345/217/202/350/200/203/02-Composables.md +0 -92
- package/src/api/docs.js +0 -106
- package/src/components/SearchPanel.vue +0 -90
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 图片格式转换工具(共享模块)
|
|
3
|
+
* 提供 imgToPngBlob / svgToPngBlob 方法
|
|
4
|
+
* 供 useMarkdown.js 和 ImageZoom.vue 共用
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 将图片 URL 转为 PNG blob(兼容同源和跨域)
|
|
9
|
+
* 策略1:fetch 获取 → 策略2:crossOrigin Image → 策略3:无 crossOrigin 直接画
|
|
10
|
+
*/
|
|
11
|
+
export async function imgToPngBlob(src) {
|
|
12
|
+
try {
|
|
13
|
+
const resp = await fetch(src)
|
|
14
|
+
const blob = await resp.blob()
|
|
15
|
+
if (blob.type === 'image/png') return blob
|
|
16
|
+
return blobToCanvasPng(blob)
|
|
17
|
+
} catch {
|
|
18
|
+
try {
|
|
19
|
+
return await loadImageToBlob(src, true)
|
|
20
|
+
} catch {
|
|
21
|
+
return loadImageToBlob(src, false)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 加载图片到 canvas 并转 PNG blob
|
|
28
|
+
* @param {string} src - 图片地址
|
|
29
|
+
* @param {boolean} useCors - 是否设置 crossOrigin
|
|
30
|
+
*/
|
|
31
|
+
function loadImageToBlob(src, useCors) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
const image = new Image()
|
|
34
|
+
if (useCors) image.crossOrigin = 'anonymous'
|
|
35
|
+
image.onload = () => {
|
|
36
|
+
try {
|
|
37
|
+
const canvas = document.createElement('canvas')
|
|
38
|
+
canvas.width = image.naturalWidth
|
|
39
|
+
canvas.height = image.naturalHeight
|
|
40
|
+
canvas.getContext('2d').drawImage(image, 0, 0)
|
|
41
|
+
canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob null')), 'image/png')
|
|
42
|
+
} catch (e) { reject(e) }
|
|
43
|
+
}
|
|
44
|
+
image.onerror = () => reject(new Error('图片加载失败'))
|
|
45
|
+
image.src = src
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 将任意图片 blob 通过 canvas 转为 PNG blob
|
|
51
|
+
*/
|
|
52
|
+
function blobToCanvasPng(srcBlob, scale = 1) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const url = URL.createObjectURL(srcBlob)
|
|
55
|
+
const image = new Image()
|
|
56
|
+
image.onload = () => {
|
|
57
|
+
const canvas = document.createElement('canvas')
|
|
58
|
+
canvas.width = image.naturalWidth * scale
|
|
59
|
+
canvas.height = image.naturalHeight * scale
|
|
60
|
+
const ctx = canvas.getContext('2d')
|
|
61
|
+
if (scale !== 1) ctx.scale(scale, scale)
|
|
62
|
+
ctx.drawImage(image, 0, 0)
|
|
63
|
+
URL.revokeObjectURL(url)
|
|
64
|
+
canvas.toBlob(b => resolve(b), 'image/png')
|
|
65
|
+
}
|
|
66
|
+
image.onerror = () => { URL.revokeObjectURL(url); reject(new Error('图片加载失败')) }
|
|
67
|
+
image.src = url
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* SVG 转 PNG blob(处理 foreignObject 导致的 tainted canvas 问题)
|
|
73
|
+
* @param {SVGElement} svgEl - SVG DOM 元素
|
|
74
|
+
* @param {number} scale - 缩放倍数,默认 2
|
|
75
|
+
*/
|
|
76
|
+
export function svgToPngBlob(svgEl, scale = 2) {
|
|
77
|
+
return new Promise((resolve, reject) => {
|
|
78
|
+
const clone = svgEl.cloneNode(true)
|
|
79
|
+
// 从 viewBox 或属性中获取 SVG 实际尺寸
|
|
80
|
+
const viewBox = clone.getAttribute('viewBox')
|
|
81
|
+
let svgWidth, svgHeight
|
|
82
|
+
if (viewBox) {
|
|
83
|
+
const parts = viewBox.split(/[\s,]+/)
|
|
84
|
+
svgWidth = parseFloat(parts[2])
|
|
85
|
+
svgHeight = parseFloat(parts[3])
|
|
86
|
+
}
|
|
87
|
+
if (!svgWidth || !svgHeight) {
|
|
88
|
+
svgWidth = parseFloat(clone.getAttribute('width')) || svgEl.getBoundingClientRect().width || 800
|
|
89
|
+
svgHeight = parseFloat(clone.getAttribute('height')) || svgEl.getBoundingClientRect().height || 600
|
|
90
|
+
}
|
|
91
|
+
// 显式设置 width/height,确保 Image 加载时尺寸正确
|
|
92
|
+
clone.setAttribute('width', svgWidth)
|
|
93
|
+
clone.setAttribute('height', svgHeight)
|
|
94
|
+
// 将 foreignObject 替换为 text 元素,避免 canvas 被污染
|
|
95
|
+
clone.querySelectorAll('foreignObject').forEach(fo => {
|
|
96
|
+
const text = fo.textContent.trim()
|
|
97
|
+
const x = fo.getAttribute('x') || '0'
|
|
98
|
+
const y = fo.getAttribute('y') || '0'
|
|
99
|
+
const width = fo.getAttribute('width') || '100'
|
|
100
|
+
const height = fo.getAttribute('height') || '20'
|
|
101
|
+
const textEl = document.createElementNS('http://www.w3.org/2000/svg', 'text')
|
|
102
|
+
textEl.setAttribute('x', String(parseFloat(x) + parseFloat(width) / 2))
|
|
103
|
+
textEl.setAttribute('y', String(parseFloat(y) + parseFloat(height) / 2 + 5))
|
|
104
|
+
textEl.setAttribute('text-anchor', 'middle')
|
|
105
|
+
textEl.setAttribute('font-size', '14')
|
|
106
|
+
textEl.setAttribute('fill', '#455a64')
|
|
107
|
+
textEl.textContent = text
|
|
108
|
+
fo.parentNode.replaceChild(textEl, fo)
|
|
109
|
+
})
|
|
110
|
+
const svgData = new XMLSerializer().serializeToString(clone)
|
|
111
|
+
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' })
|
|
112
|
+
const url = URL.createObjectURL(svgBlob)
|
|
113
|
+
const image = new Image()
|
|
114
|
+
image.onload = () => {
|
|
115
|
+
const w = svgWidth * scale
|
|
116
|
+
const h = svgHeight * scale
|
|
117
|
+
const canvas = document.createElement('canvas')
|
|
118
|
+
canvas.width = w
|
|
119
|
+
canvas.height = h
|
|
120
|
+
const ctx = canvas.getContext('2d')
|
|
121
|
+
ctx.scale(scale, scale)
|
|
122
|
+
ctx.drawImage(image, 0, 0, svgWidth, svgHeight)
|
|
123
|
+
URL.revokeObjectURL(url)
|
|
124
|
+
canvas.toBlob(b => b ? resolve(b) : reject(new Error('toBlob null')), 'image/png')
|
|
125
|
+
}
|
|
126
|
+
image.onerror = () => { URL.revokeObjectURL(url); reject(new Error('SVG加载失败')) }
|
|
127
|
+
image.src = url
|
|
128
|
+
})
|
|
129
|
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import crypto from 'crypto'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Vite 插件:文档 API(统一使用 /@user-docs-* 路由前缀)
|
|
7
|
+
*
|
|
8
|
+
* 路由:
|
|
9
|
+
* - GET /@user-docs-list 返回文档树 + ETag
|
|
10
|
+
* - GET /@user-docs/xxx 返回文件内容(md / 图片等)
|
|
11
|
+
* - POST /@upload-image 上传图片
|
|
12
|
+
* - POST /api/save 保存文件内容
|
|
13
|
+
* - POST /api/create 创建文件或文件夹
|
|
14
|
+
* - POST /api/delete 删除文件或文件夹
|
|
15
|
+
* - POST /api/rename 重命名
|
|
16
|
+
* - POST /api/reorder 批量重编号
|
|
17
|
+
* - POST /api/move 移动文件/文件夹
|
|
18
|
+
*/
|
|
19
|
+
export default function docApiPlugin(docsDir = '.') {
|
|
20
|
+
const resolvedDocsDir = path.resolve(docsDir)
|
|
21
|
+
|
|
22
|
+
// 计算内容的 ETag
|
|
23
|
+
function etag(content) {
|
|
24
|
+
return '"' + crypto.createHash('md5').update(content).digest('hex') + '"'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 安全路径验证:使用 path.relative 防止路径遍历
|
|
28
|
+
function safePath(filePath) {
|
|
29
|
+
const fullPath = path.resolve(resolvedDocsDir, filePath)
|
|
30
|
+
const relative = path.relative(resolvedDocsDir, fullPath)
|
|
31
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
32
|
+
return null // 路径越界
|
|
33
|
+
}
|
|
34
|
+
return fullPath
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 解析 POST 请求的 JSON body
|
|
38
|
+
function parseJsonBody(req) {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
let body = ''
|
|
41
|
+
req.on('data', chunk => { body += chunk })
|
|
42
|
+
req.on('end', () => {
|
|
43
|
+
try { resolve(JSON.parse(body)) }
|
|
44
|
+
catch (e) { reject(e) }
|
|
45
|
+
})
|
|
46
|
+
req.on('error', reject)
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 递归扫描 .md 文件,返回树结构(与 CLI 模式 scanDocs 一致)
|
|
51
|
+
function scanDocs(dir, basePath = '', level = 0) {
|
|
52
|
+
const items = []
|
|
53
|
+
if (!fs.existsSync(dir)) return items
|
|
54
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
55
|
+
.filter(e => !e.name.startsWith('.') && e.name !== 'node_modules')
|
|
56
|
+
.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
|
|
57
|
+
|
|
58
|
+
for (const entry of entries) {
|
|
59
|
+
const fullPath = path.join(dir, entry.name)
|
|
60
|
+
const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name
|
|
61
|
+
|
|
62
|
+
if (entry.isDirectory()) {
|
|
63
|
+
const children = scanDocs(fullPath, relativePath, level + 1)
|
|
64
|
+
if (children.length > 0) {
|
|
65
|
+
const match = entry.name.match(/^(\d+)-(.+)$/)
|
|
66
|
+
items.push({
|
|
67
|
+
key: relativePath,
|
|
68
|
+
label: match ? match[2] : entry.name,
|
|
69
|
+
order: match ? parseInt(match[1]) : 999,
|
|
70
|
+
type: 'folder',
|
|
71
|
+
level,
|
|
72
|
+
expanded: false,
|
|
73
|
+
children
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
} else if (entry.name.endsWith('.md')) {
|
|
77
|
+
const match = entry.name.match(/^(\d+)-(.+)\.md$/)
|
|
78
|
+
const label = match ? match[2] : entry.name.replace(/\.md$/, '')
|
|
79
|
+
items.push({
|
|
80
|
+
key: relativePath.replace(/\.md$/, ''),
|
|
81
|
+
label,
|
|
82
|
+
order: match ? parseInt(match[1]) : 999,
|
|
83
|
+
type: 'file',
|
|
84
|
+
level,
|
|
85
|
+
path: `/@user-docs/${relativePath}`
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
items.sort((a, b) => a.order - b.order)
|
|
91
|
+
return items
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// MIME 类型映射
|
|
95
|
+
const mimeMap = {
|
|
96
|
+
md: 'text/plain; charset=utf-8',
|
|
97
|
+
png: 'image/png',
|
|
98
|
+
jpg: 'image/jpeg',
|
|
99
|
+
jpeg: 'image/jpeg',
|
|
100
|
+
gif: 'image/gif',
|
|
101
|
+
webp: 'image/webp',
|
|
102
|
+
svg: 'image/svg+xml',
|
|
103
|
+
bmp: 'image/bmp',
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
name: 'vite-plugin-doc-api',
|
|
108
|
+
configureServer(server) {
|
|
109
|
+
// 统一中间件
|
|
110
|
+
server.middlewares.use((req, res, next) => {
|
|
111
|
+
// GET /@user-docs-list — 返回文档树 + ETag
|
|
112
|
+
if (req.url === '/@user-docs-list' && req.method === 'GET') {
|
|
113
|
+
const docs = scanDocs(resolvedDocsDir)
|
|
114
|
+
const body = JSON.stringify(docs)
|
|
115
|
+
const tag = etag(body)
|
|
116
|
+
if (req.headers['if-none-match'] === tag) {
|
|
117
|
+
res.statusCode = 304; res.end(); return
|
|
118
|
+
}
|
|
119
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
120
|
+
res.setHeader('ETag', tag)
|
|
121
|
+
res.end(body)
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// GET /@user-docs/xxx — 返回文件内容(md / 图片等)
|
|
126
|
+
if (req.url?.startsWith('/@user-docs/') && req.method === 'GET') {
|
|
127
|
+
const filePath = safePath(decodeURIComponent(req.url.replace('/@user-docs/', '')))
|
|
128
|
+
if (!filePath) {
|
|
129
|
+
res.statusCode = 403; res.end('禁止访问'); return
|
|
130
|
+
}
|
|
131
|
+
if (!fs.existsSync(filePath)) {
|
|
132
|
+
res.statusCode = 404; res.end('文件不存在'); return
|
|
133
|
+
}
|
|
134
|
+
const extName = filePath.split('.').pop().toLowerCase()
|
|
135
|
+
const contentType = mimeMap[extName] || 'application/octet-stream'
|
|
136
|
+
res.setHeader('Content-Type', contentType)
|
|
137
|
+
// 附带最后修改时间
|
|
138
|
+
const stat = fs.statSync(filePath)
|
|
139
|
+
res.setHeader('X-Last-Modified', stat.mtime.toISOString())
|
|
140
|
+
// 文本文件支持 ETag
|
|
141
|
+
if (contentType.startsWith('text/')) {
|
|
142
|
+
const content = fs.readFileSync(filePath, 'utf-8')
|
|
143
|
+
const tag = etag(content)
|
|
144
|
+
if (req.headers['if-none-match'] === tag) {
|
|
145
|
+
res.statusCode = 304; res.end(); return
|
|
146
|
+
}
|
|
147
|
+
res.setHeader('ETag', tag)
|
|
148
|
+
res.end(content)
|
|
149
|
+
} else {
|
|
150
|
+
res.end(fs.readFileSync(filePath))
|
|
151
|
+
}
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// POST /@upload-image — 上传图片
|
|
156
|
+
if (req.url === '/@upload-image' && req.method === 'POST') {
|
|
157
|
+
const chunks = []
|
|
158
|
+
req.on('data', chunk => chunks.push(chunk))
|
|
159
|
+
req.on('end', () => {
|
|
160
|
+
try {
|
|
161
|
+
const body = Buffer.concat(chunks)
|
|
162
|
+
const docPath = decodeURIComponent(req.headers['x-doc-path'] || '')
|
|
163
|
+
const fileName = decodeURIComponent(req.headers['x-file-name'] || `img-${Date.now()}.png`)
|
|
164
|
+
|
|
165
|
+
if (!docPath) {
|
|
166
|
+
res.statusCode = 400; res.end('缺少文档路径'); return
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const docDir = path.dirname(path.resolve(resolvedDocsDir, docPath))
|
|
170
|
+
const relative = path.relative(resolvedDocsDir, docDir)
|
|
171
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
172
|
+
res.statusCode = 403; res.end('禁止访问'); return
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const assetsDir = path.join(docDir, 'assets')
|
|
176
|
+
fs.mkdirSync(assetsDir, { recursive: true })
|
|
177
|
+
|
|
178
|
+
const ext = path.extname(fileName) || '.png'
|
|
179
|
+
const baseName = `img-${Date.now()}`
|
|
180
|
+
const targetName = `${baseName}${ext}`
|
|
181
|
+
const targetPath = path.join(assetsDir, targetName)
|
|
182
|
+
|
|
183
|
+
fs.writeFileSync(targetPath, body)
|
|
184
|
+
|
|
185
|
+
// 返回 /@user-docs/ 前缀的路径
|
|
186
|
+
const docDirRel = path.dirname(docPath)
|
|
187
|
+
const encodedDir = docDirRel && docDirRel !== '.'
|
|
188
|
+
? docDirRel.split('/').map(encodeURIComponent).join('/')
|
|
189
|
+
: ''
|
|
190
|
+
const imageUrl = encodedDir
|
|
191
|
+
? `/@user-docs/${encodedDir}/assets/${targetName}`
|
|
192
|
+
: `/@user-docs/assets/${targetName}`
|
|
193
|
+
|
|
194
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
195
|
+
res.end(JSON.stringify({ url: imageUrl }))
|
|
196
|
+
} catch (e) {
|
|
197
|
+
res.statusCode = 500; res.end(e.message)
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
next()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
// POST /api/create — 创建文件或文件夹
|
|
207
|
+
server.middlewares.use('/api/create', async (req, res) => {
|
|
208
|
+
if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
|
|
209
|
+
try {
|
|
210
|
+
const { path: filePath, type } = await parseJsonBody(req)
|
|
211
|
+
if (!filePath) {
|
|
212
|
+
res.statusCode = 400; res.end('缺少 path'); return
|
|
213
|
+
}
|
|
214
|
+
const fullPath = safePath(filePath)
|
|
215
|
+
if (!fullPath) {
|
|
216
|
+
res.statusCode = 403; res.end('禁止访问'); return
|
|
217
|
+
}
|
|
218
|
+
if (fs.existsSync(fullPath)) {
|
|
219
|
+
res.statusCode = 409; res.end('已存在同名文件或文件夹'); return
|
|
220
|
+
}
|
|
221
|
+
if (type === 'folder') {
|
|
222
|
+
fs.mkdirSync(fullPath, { recursive: true })
|
|
223
|
+
} else {
|
|
224
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true })
|
|
225
|
+
fs.writeFileSync(fullPath, '', 'utf-8')
|
|
226
|
+
}
|
|
227
|
+
res.setHeader('Content-Type', 'application/json')
|
|
228
|
+
res.end(JSON.stringify({ ok: true }))
|
|
229
|
+
} catch (e) {
|
|
230
|
+
res.statusCode = 500; res.end(e.message)
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
// POST /api/delete — 删除文件或文件夹
|
|
235
|
+
server.middlewares.use('/api/delete', async (req, res) => {
|
|
236
|
+
if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
|
|
237
|
+
try {
|
|
238
|
+
const { path: filePath } = await parseJsonBody(req)
|
|
239
|
+
if (!filePath) {
|
|
240
|
+
res.statusCode = 400; res.end('缺少 path'); return
|
|
241
|
+
}
|
|
242
|
+
const fullPath = safePath(filePath)
|
|
243
|
+
if (!fullPath) {
|
|
244
|
+
res.statusCode = 403; res.end('禁止访问'); return
|
|
245
|
+
}
|
|
246
|
+
if (!fs.existsSync(fullPath)) {
|
|
247
|
+
res.statusCode = 404; res.end('文件不存在'); return
|
|
248
|
+
}
|
|
249
|
+
fs.rmSync(fullPath, { recursive: true, force: true })
|
|
250
|
+
res.setHeader('Content-Type', 'application/json')
|
|
251
|
+
res.end(JSON.stringify({ ok: true }))
|
|
252
|
+
} catch (e) {
|
|
253
|
+
res.statusCode = 500; res.end(e.message)
|
|
254
|
+
}
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
// POST /api/rename — 重命名文件或文件夹
|
|
258
|
+
server.middlewares.use('/api/rename', async (req, res) => {
|
|
259
|
+
if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
|
|
260
|
+
try {
|
|
261
|
+
const { oldPath, newPath } = await parseJsonBody(req)
|
|
262
|
+
if (!oldPath || !newPath) {
|
|
263
|
+
res.statusCode = 400; res.end('缺少 oldPath 或 newPath'); return
|
|
264
|
+
}
|
|
265
|
+
const fullOld = safePath(oldPath)
|
|
266
|
+
const fullNew = safePath(newPath)
|
|
267
|
+
if (!fullOld || !fullNew) {
|
|
268
|
+
res.statusCode = 403; res.end('禁止访问'); return
|
|
269
|
+
}
|
|
270
|
+
if (!fs.existsSync(fullOld)) {
|
|
271
|
+
res.statusCode = 404; res.end('源文件不存在'); return
|
|
272
|
+
}
|
|
273
|
+
if (fs.existsSync(fullNew)) {
|
|
274
|
+
res.statusCode = 409; res.end('目标名称已存在'); return
|
|
275
|
+
}
|
|
276
|
+
fs.mkdirSync(path.dirname(fullNew), { recursive: true })
|
|
277
|
+
fs.renameSync(fullOld, fullNew)
|
|
278
|
+
res.setHeader('Content-Type', 'application/json')
|
|
279
|
+
res.end(JSON.stringify({ ok: true }))
|
|
280
|
+
} catch (e) {
|
|
281
|
+
res.statusCode = 500; res.end(e.message)
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
// POST /api/reorder — 批量重编号
|
|
286
|
+
server.middlewares.use('/api/reorder', async (req, res) => {
|
|
287
|
+
if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
|
|
288
|
+
try {
|
|
289
|
+
const { items } = await parseJsonBody(req)
|
|
290
|
+
if (!Array.isArray(items) || items.length === 0) {
|
|
291
|
+
res.statusCode = 400; res.end('缺少 items'); return
|
|
292
|
+
}
|
|
293
|
+
// 验证所有路径
|
|
294
|
+
for (const { oldPath, newPath } of items) {
|
|
295
|
+
if (!safePath(oldPath) || !safePath(newPath)) {
|
|
296
|
+
res.statusCode = 403; res.end('禁止访问'); return
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// 先全部重命名为临时文件,再重命名为目标文件(避免冲突)
|
|
300
|
+
const tempMap = []
|
|
301
|
+
for (let i = 0; i < items.length; i++) {
|
|
302
|
+
const { oldPath } = items[i]
|
|
303
|
+
const fullOld = safePath(oldPath)
|
|
304
|
+
if (!fullOld || !fs.existsSync(fullOld)) continue
|
|
305
|
+
const tempName = fullOld + `.__reorder_tmp_${i}__`
|
|
306
|
+
fs.renameSync(fullOld, tempName)
|
|
307
|
+
tempMap.push({ temp: tempName, newPath: items[i].newPath })
|
|
308
|
+
}
|
|
309
|
+
for (const { temp, newPath: np } of tempMap) {
|
|
310
|
+
const fullNew = safePath(np)
|
|
311
|
+
if (!fullNew) continue
|
|
312
|
+
fs.mkdirSync(path.dirname(fullNew), { recursive: true })
|
|
313
|
+
fs.renameSync(temp, fullNew)
|
|
314
|
+
}
|
|
315
|
+
res.setHeader('Content-Type', 'application/json')
|
|
316
|
+
res.end(JSON.stringify({ ok: true }))
|
|
317
|
+
} catch (e) {
|
|
318
|
+
res.statusCode = 500; res.end(e.message)
|
|
319
|
+
}
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
// POST /api/move — 移动文件/文件夹
|
|
323
|
+
server.middlewares.use('/api/move', async (req, res) => {
|
|
324
|
+
if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
|
|
325
|
+
try {
|
|
326
|
+
const { oldPath, newPath } = await parseJsonBody(req)
|
|
327
|
+
if (!oldPath || !newPath) {
|
|
328
|
+
res.statusCode = 400; res.end('缺少 oldPath 或 newPath'); return
|
|
329
|
+
}
|
|
330
|
+
const fullOld = safePath(oldPath)
|
|
331
|
+
const fullNew = safePath(newPath)
|
|
332
|
+
if (!fullOld || !fullNew) {
|
|
333
|
+
res.statusCode = 403; res.end('禁止访问'); return
|
|
334
|
+
}
|
|
335
|
+
if (!fs.existsSync(fullOld)) {
|
|
336
|
+
res.statusCode = 404; res.end('源文件不存在'); return
|
|
337
|
+
}
|
|
338
|
+
fs.mkdirSync(path.dirname(fullNew), { recursive: true })
|
|
339
|
+
fs.renameSync(fullOld, fullNew)
|
|
340
|
+
res.setHeader('Content-Type', 'application/json')
|
|
341
|
+
res.end(JSON.stringify({ ok: true }))
|
|
342
|
+
} catch (e) {
|
|
343
|
+
res.statusCode = 500; res.end(e.message)
|
|
344
|
+
}
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
// POST /api/save — 保存文件内容
|
|
348
|
+
server.middlewares.use('/api/save', async (req, res) => {
|
|
349
|
+
if (req.method !== 'POST') { res.statusCode = 405; res.end(); return }
|
|
350
|
+
try {
|
|
351
|
+
const { path: filePath, content } = await parseJsonBody(req)
|
|
352
|
+
if (!filePath || content == null) {
|
|
353
|
+
res.statusCode = 400; res.end('缺少 path 或 content'); return
|
|
354
|
+
}
|
|
355
|
+
const fullPath = safePath(filePath)
|
|
356
|
+
if (!fullPath) {
|
|
357
|
+
res.statusCode = 403; res.end('禁止访问'); return
|
|
358
|
+
}
|
|
359
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true })
|
|
360
|
+
fs.writeFileSync(fullPath, content, 'utf-8')
|
|
361
|
+
res.setHeader('Content-Type', 'application/json')
|
|
362
|
+
res.end(JSON.stringify({ ok: true }))
|
|
363
|
+
} catch (e) {
|
|
364
|
+
res.statusCode = 500; res.end(e.message)
|
|
365
|
+
}
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
package/vite.config.js
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { defineConfig } from 'vite'
|
|
2
2
|
import vue from '@vitejs/plugin-vue'
|
|
3
3
|
import { config } from './src/config.js'
|
|
4
|
+
import docApiPlugin from './vite-plugin-doc-api.js'
|
|
4
5
|
|
|
5
6
|
export default defineConfig({
|
|
6
|
-
plugins: [vue()],
|
|
7
|
+
plugins: [vue(), docApiPlugin('public/docs')],
|
|
7
8
|
server: {
|
|
8
9
|
port: config.defaultPort
|
|
9
10
|
},
|
|
10
11
|
optimizeDeps: {
|
|
11
|
-
include: [
|
|
12
|
+
include: [
|
|
13
|
+
'vue',
|
|
14
|
+
'marked',
|
|
15
|
+
'mermaid',
|
|
16
|
+
],
|
|
12
17
|
}
|
|
13
18
|
})
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
# Mermaid 图表
|
|
2
|
-
|
|
3
|
-
md2ui 内置了 Mermaid 支持,可以直接在 Markdown 中绘制各类图表。
|
|
4
|
-
|
|
5
|
-
## 流程图
|
|
6
|
-
|
|
7
|
-
```mermaid
|
|
8
|
-
flowchart TD
|
|
9
|
-
A[开始] --> B{是否有文档?}
|
|
10
|
-
B -->|是| C[解析 Markdown]
|
|
11
|
-
B -->|否| D[显示空状态]
|
|
12
|
-
C --> E[渲染 HTML]
|
|
13
|
-
E --> F[展示页面]
|
|
14
|
-
D --> F
|
|
15
|
-
F --> G[结束]
|
|
16
|
-
```
|
|
17
|
-
|
|
18
|
-
## 时序图
|
|
19
|
-
|
|
20
|
-
```mermaid
|
|
21
|
-
sequenceDiagram
|
|
22
|
-
participant U as 用户
|
|
23
|
-
participant B as 浏览器
|
|
24
|
-
participant S as 服务器
|
|
25
|
-
|
|
26
|
-
U->>B: 点击文档链接
|
|
27
|
-
B->>S: 请求 Markdown 文件
|
|
28
|
-
S-->>B: 返回文件内容
|
|
29
|
-
B->>B: 解析并渲染
|
|
30
|
-
B-->>U: 显示文档页面
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
## 类图
|
|
34
|
-
|
|
35
|
-
```mermaid
|
|
36
|
-
classDiagram
|
|
37
|
-
class Document {
|
|
38
|
-
+String title
|
|
39
|
-
+String content
|
|
40
|
-
+Date createdAt
|
|
41
|
-
+render()
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
class Folder {
|
|
45
|
-
+String name
|
|
46
|
-
+Document[] documents
|
|
47
|
-
+Folder[] subfolders
|
|
48
|
-
+expand()
|
|
49
|
-
+collapse()
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
class Navigator {
|
|
53
|
-
+Folder root
|
|
54
|
-
+Document current
|
|
55
|
-
+navigate(path)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
Folder "1" *-- "*" Document
|
|
59
|
-
Folder "1" *-- "*" Folder
|
|
60
|
-
Navigator --> Folder
|
|
61
|
-
Navigator --> Document
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
## 状态图
|
|
65
|
-
|
|
66
|
-
```mermaid
|
|
67
|
-
stateDiagram-v2
|
|
68
|
-
[*] --> 空闲
|
|
69
|
-
空闲 --> 加载中: 请求文档
|
|
70
|
-
加载中 --> 已加载: 加载成功
|
|
71
|
-
加载中 --> 错误: 加载失败
|
|
72
|
-
已加载 --> 空闲: 切换文档
|
|
73
|
-
错误 --> 空闲: 重试
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
## 甘特图
|
|
77
|
-
|
|
78
|
-
```mermaid
|
|
79
|
-
gantt
|
|
80
|
-
title 项目开发计划
|
|
81
|
-
dateFormat YYYY-MM-DD
|
|
82
|
-
section 基础功能
|
|
83
|
-
Markdown 渲染 :done, a1, 2024-01-01, 7d
|
|
84
|
-
目录导航 :done, a2, after a1, 5d
|
|
85
|
-
section 增强功能
|
|
86
|
-
Mermaid 支持 :done, b1, after a2, 3d
|
|
87
|
-
代码高亮 :done, b2, after b1, 2d
|
|
88
|
-
section 优化
|
|
89
|
-
性能优化 :active, c1, after b2, 5d
|
|
90
|
-
响应式适配 :c2, after c1, 3d
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
## 饼图
|
|
94
|
-
|
|
95
|
-
```mermaid
|
|
96
|
-
pie title 技术栈占比
|
|
97
|
-
"Vue 3" : 40
|
|
98
|
-
"Vite" : 20
|
|
99
|
-
"Marked" : 15
|
|
100
|
-
"Mermaid" : 15
|
|
101
|
-
"其他" : 10
|
|
102
|
-
```
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
# 目录结构
|
|
2
|
-
|
|
3
|
-
了解 md2ui 的项目结构,有助于你进行定制开发。
|
|
4
|
-
|
|
5
|
-
## 项目结构
|
|
6
|
-
|
|
7
|
-
```
|
|
8
|
-
md2ui/
|
|
9
|
-
├── public/ # 静态资源
|
|
10
|
-
│ ├── README.md # 首页内容
|
|
11
|
-
│ ├── logo.svg # 网站图标
|
|
12
|
-
│ └── docs/ # 文档目录
|
|
13
|
-
├── src/
|
|
14
|
-
│ ├── api/ # API 接口
|
|
15
|
-
│ │ └── docs.js # 文档列表获取
|
|
16
|
-
│ ├── components/ # Vue 组件
|
|
17
|
-
│ │ ├── Logo.vue # Logo 组件
|
|
18
|
-
│ │ ├── TreeNode.vue # 树形节点
|
|
19
|
-
│ │ ├── TableOfContents.vue # 目录组件
|
|
20
|
-
│ │ └── ImageZoom.vue # 图片放大
|
|
21
|
-
│ ├── composables/ # 组合式函数
|
|
22
|
-
│ │ ├── useMarkdown.js # Markdown 渲染
|
|
23
|
-
│ │ ├── useScroll.js # 滚动处理
|
|
24
|
-
│ │ └── useResize.js # 拖拽调整
|
|
25
|
-
│ ├── App.vue # 主组件
|
|
26
|
-
│ ├── main.js # 入口文件
|
|
27
|
-
│ └── style.css # 全局样式
|
|
28
|
-
├── index.html # HTML 模板
|
|
29
|
-
├── vite.config.js # Vite 配置
|
|
30
|
-
└── package.json # 项目配置
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
## 核心模块说明
|
|
34
|
-
|
|
35
|
-
### api/docs.js
|
|
36
|
-
|
|
37
|
-
负责扫描 `public/docs/` 目录,构建文档树结构。
|
|
38
|
-
|
|
39
|
-
### composables/useMarkdown.js
|
|
40
|
-
|
|
41
|
-
封装 Markdown 解析逻辑,包括:
|
|
42
|
-
- 使用 marked 解析 Markdown
|
|
43
|
-
- 提取标题生成目录
|
|
44
|
-
- 处理 Mermaid 代码块
|
|
45
|
-
|
|
46
|
-
### composables/useScroll.js
|
|
47
|
-
|
|
48
|
-
处理滚动相关逻辑:
|
|
49
|
-
- 计算阅读进度
|
|
50
|
-
- 高亮当前章节
|
|
51
|
-
- 返回顶部功能
|
|
52
|
-
|
|
53
|
-
### composables/useResize.js
|
|
54
|
-
|
|
55
|
-
实现拖拽调整宽度功能。
|