md2ui 1.0.3 → 1.0.8
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 +104 -38
- package/bin/build.js +609 -0
- package/bin/md2ui.js +112 -63
- package/index.html +43 -1
- package/package.json +6 -5
- package/src/App.vue +106 -170
- package/src/api/docs.js +2 -1
- package/src/components/AppSidebar.vue +102 -0
- package/src/components/DocContent.vue +39 -0
- package/src/components/ImageZoom.vue +4 -2
- package/src/components/Logo.vue +5 -9
- package/src/components/MobileHeader.vue +20 -0
- package/src/components/MobileToc.vue +40 -0
- package/src/components/SearchPanel.vue +90 -0
- package/src/components/TableOfContents.vue +2 -2
- package/src/components/TopBar.vue +144 -0
- package/src/components/WelcomePage.vue +251 -0
- package/src/composables/useDocHash.js +66 -0
- package/src/composables/useDocManager.js +239 -0
- package/src/composables/useDocTree.js +80 -0
- package/src/composables/useFrontmatter.js +41 -0
- package/src/composables/useMarkdown.js +316 -94
- package/src/composables/useMobile.js +29 -0
- package/src/composables/useScroll.js +9 -15
- package/src/composables/useSearch.js +133 -0
- package/src/hljs-theme.css +104 -0
- package/src/main.js +1 -0
- package/src/style.css +935 -213
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="welcome-page">
|
|
3
|
+
<div class="welcome-hero">
|
|
4
|
+
<!-- 图标 + 标题同行 -->
|
|
5
|
+
<div class="welcome-brand">
|
|
6
|
+
<div class="welcome-logo">
|
|
7
|
+
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
8
|
+
<rect x="6" y="4" width="20" height="24" rx="2" stroke="currentColor" stroke-width="2" fill="none"/>
|
|
9
|
+
<line x1="10" y1="10" x2="22" y2="10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
|
10
|
+
<line x1="10" y1="16" x2="22" y2="16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
|
11
|
+
<line x1="10" y1="22" x2="17" y2="22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
|
12
|
+
</svg>
|
|
13
|
+
</div>
|
|
14
|
+
<h1 class="welcome-title">md2ui</h1>
|
|
15
|
+
</div>
|
|
16
|
+
<p class="welcome-desc">将 Markdown 文档转换为美观易读的网页</p>
|
|
17
|
+
<!-- 操作区 -->
|
|
18
|
+
<div class="welcome-actions">
|
|
19
|
+
<button class="welcome-btn primary" @click="$emit('start')">
|
|
20
|
+
<BookOpen :size="16" />
|
|
21
|
+
<span>开始阅读</span>
|
|
22
|
+
<ArrowRight :size="14" class="btn-arrow" />
|
|
23
|
+
</button>
|
|
24
|
+
</div>
|
|
25
|
+
<!-- GitHub -->
|
|
26
|
+
<a class="welcome-github" href="https://github.com/devsneed/md2ui" target="_blank">
|
|
27
|
+
<GitHubIcon :size="16" />
|
|
28
|
+
<span>GitHub</span>
|
|
29
|
+
<span class="welcome-github-sep"></span>
|
|
30
|
+
<span class="welcome-github-repo">devsneed/md2ui</span>
|
|
31
|
+
<ExternalLink :size="12" class="welcome-github-arrow" />
|
|
32
|
+
</a>
|
|
33
|
+
</div>
|
|
34
|
+
<!-- 特性卡片 -->
|
|
35
|
+
<div class="welcome-features">
|
|
36
|
+
<div class="feature-card" v-for="f in features" :key="f.title">
|
|
37
|
+
<div class="feature-icon-wrap">
|
|
38
|
+
<component :is="f.icon" :size="20" />
|
|
39
|
+
</div>
|
|
40
|
+
<h3>{{ f.title }}</h3>
|
|
41
|
+
<p>{{ f.desc }}</p>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
</template>
|
|
46
|
+
|
|
47
|
+
<script setup>
|
|
48
|
+
import { BookOpen, Github as GitHubIcon, ArrowRight, ExternalLink, FileText, Palette, Zap } from 'lucide-vue-next'
|
|
49
|
+
|
|
50
|
+
defineEmits(['start'])
|
|
51
|
+
|
|
52
|
+
const features = [
|
|
53
|
+
{ icon: FileText, title: 'Markdown 驱动', desc: '直接读取 .md 文件,零配置即可使用' },
|
|
54
|
+
{ icon: Palette, title: '简洁优雅', desc: '清晰的排版与舒适的阅读体验' },
|
|
55
|
+
{ icon: Zap, title: '开箱即用', desc: '一行命令启动,支持目录结构与 Mermaid 图表' }
|
|
56
|
+
]
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<style scoped>
|
|
60
|
+
.welcome-page {
|
|
61
|
+
display: flex;
|
|
62
|
+
flex-direction: column;
|
|
63
|
+
align-items: center;
|
|
64
|
+
justify-content: center;
|
|
65
|
+
min-height: 100%;
|
|
66
|
+
padding: 48px 24px;
|
|
67
|
+
user-select: none;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.welcome-hero {
|
|
71
|
+
display: flex;
|
|
72
|
+
flex-direction: column;
|
|
73
|
+
align-items: center;
|
|
74
|
+
gap: 12px;
|
|
75
|
+
margin-bottom: 40px;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.welcome-brand {
|
|
79
|
+
display: flex;
|
|
80
|
+
align-items: center;
|
|
81
|
+
gap: 12px;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.welcome-logo {
|
|
85
|
+
display: flex;
|
|
86
|
+
align-items: center;
|
|
87
|
+
justify-content: center;
|
|
88
|
+
width: 44px;
|
|
89
|
+
height: 44px;
|
|
90
|
+
background: var(--color-accent-bg);
|
|
91
|
+
color: var(--color-accent);
|
|
92
|
+
border-radius: 10px;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.welcome-title {
|
|
96
|
+
font-size: 32px;
|
|
97
|
+
font-weight: 600;
|
|
98
|
+
color: var(--color-text);
|
|
99
|
+
letter-spacing: -0.03em;
|
|
100
|
+
border: none;
|
|
101
|
+
padding: 0;
|
|
102
|
+
margin: 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.welcome-desc {
|
|
106
|
+
font-size: 15px;
|
|
107
|
+
color: var(--color-text-secondary);
|
|
108
|
+
margin: 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.welcome-actions {
|
|
112
|
+
margin-top: 4px;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.welcome-btn {
|
|
116
|
+
display: inline-flex;
|
|
117
|
+
align-items: center;
|
|
118
|
+
gap: 8px;
|
|
119
|
+
padding: 10px 24px;
|
|
120
|
+
border-radius: 6px;
|
|
121
|
+
font-size: 14px;
|
|
122
|
+
font-weight: 500;
|
|
123
|
+
cursor: pointer;
|
|
124
|
+
transition: all 0.15s;
|
|
125
|
+
text-decoration: none;
|
|
126
|
+
border: none;
|
|
127
|
+
font-family: inherit;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.welcome-btn.primary {
|
|
131
|
+
background: var(--color-accent);
|
|
132
|
+
color: #fff;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.welcome-btn.primary:hover {
|
|
136
|
+
background: var(--color-accent-hover);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.btn-arrow {
|
|
140
|
+
opacity: 0.7;
|
|
141
|
+
transition: all 0.15s;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.welcome-btn.primary:hover .btn-arrow {
|
|
145
|
+
opacity: 1;
|
|
146
|
+
transform: translateX(2px);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.welcome-github {
|
|
150
|
+
display: inline-flex;
|
|
151
|
+
align-items: center;
|
|
152
|
+
gap: 8px;
|
|
153
|
+
padding: 8px 16px;
|
|
154
|
+
border-radius: 6px;
|
|
155
|
+
font-size: 13px;
|
|
156
|
+
font-weight: 500;
|
|
157
|
+
color: var(--color-text);
|
|
158
|
+
text-decoration: none;
|
|
159
|
+
transition: all 0.15s;
|
|
160
|
+
border: 1px solid var(--color-border);
|
|
161
|
+
background: var(--color-bg-secondary);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.welcome-github:hover {
|
|
165
|
+
background: var(--color-bg-tertiary);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.welcome-github-sep {
|
|
169
|
+
width: 1px;
|
|
170
|
+
height: 12px;
|
|
171
|
+
background: var(--color-border);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.welcome-github-repo {
|
|
175
|
+
color: var(--color-text-secondary);
|
|
176
|
+
font-weight: 400;
|
|
177
|
+
font-size: 12px;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.welcome-github-arrow {
|
|
181
|
+
color: var(--color-text-tertiary);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
.welcome-features {
|
|
185
|
+
display: flex;
|
|
186
|
+
gap: 16px;
|
|
187
|
+
max-width: 640px;
|
|
188
|
+
width: 100%;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.feature-card {
|
|
192
|
+
flex: 1;
|
|
193
|
+
text-align: center;
|
|
194
|
+
padding: 20px 14px 16px;
|
|
195
|
+
border-radius: 6px;
|
|
196
|
+
background: var(--color-bg-secondary);
|
|
197
|
+
border: 1px solid var(--color-border);
|
|
198
|
+
transition: all 0.15s;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.feature-card:hover {
|
|
202
|
+
border-color: var(--color-text-tertiary);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.feature-icon-wrap {
|
|
206
|
+
display: inline-flex;
|
|
207
|
+
align-items: center;
|
|
208
|
+
justify-content: center;
|
|
209
|
+
width: 36px;
|
|
210
|
+
height: 36px;
|
|
211
|
+
background: var(--color-accent-bg);
|
|
212
|
+
color: var(--color-accent);
|
|
213
|
+
border-radius: 8px;
|
|
214
|
+
margin-bottom: 10px;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.feature-card h3 {
|
|
218
|
+
font-size: 13px;
|
|
219
|
+
font-weight: 600;
|
|
220
|
+
color: var(--color-text);
|
|
221
|
+
margin-bottom: 4px;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.feature-card p {
|
|
225
|
+
font-size: 12px;
|
|
226
|
+
color: var(--color-text-secondary);
|
|
227
|
+
line-height: 1.5;
|
|
228
|
+
margin: 0;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
@media (max-width: 640px) {
|
|
232
|
+
.welcome-page {
|
|
233
|
+
padding: 32px 16px;
|
|
234
|
+
}
|
|
235
|
+
.welcome-features {
|
|
236
|
+
flex-direction: column;
|
|
237
|
+
gap: 8px;
|
|
238
|
+
}
|
|
239
|
+
.welcome-title {
|
|
240
|
+
font-size: 24px;
|
|
241
|
+
}
|
|
242
|
+
.welcome-logo {
|
|
243
|
+
width: 36px;
|
|
244
|
+
height: 36px;
|
|
245
|
+
}
|
|
246
|
+
.welcome-logo svg {
|
|
247
|
+
width: 22px;
|
|
248
|
+
height: 22px;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
</style>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// 文档 hash 生成 & 链接解析工具
|
|
2
|
+
|
|
3
|
+
// base62 字符集
|
|
4
|
+
const BASE62 = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
|
5
|
+
|
|
6
|
+
// 同步 hash:FNV-1a 变体,双轮 hash 拼接得到 64 bit,再转 base62 取 8 位
|
|
7
|
+
// 碰撞空间 62^8 ≈ 218 万亿,对文档站绰绰有余
|
|
8
|
+
function fnv1a64(str) {
|
|
9
|
+
// 第一轮 FNV-1a(seed = 标准 FNV offset basis)
|
|
10
|
+
let h1 = 0x811c9dc5 >>> 0
|
|
11
|
+
for (let i = 0; i < str.length; i++) {
|
|
12
|
+
h1 ^= str.charCodeAt(i)
|
|
13
|
+
h1 = Math.imul(h1, 0x01000193) >>> 0
|
|
14
|
+
}
|
|
15
|
+
// 第二轮 FNV-1a(不同 seed,避免对称碰撞)
|
|
16
|
+
let h2 = 0x050c5d1f >>> 0
|
|
17
|
+
for (let i = 0; i < str.length; i++) {
|
|
18
|
+
h2 ^= str.charCodeAt(i)
|
|
19
|
+
h2 = Math.imul(h2, 0x01000193) >>> 0
|
|
20
|
+
}
|
|
21
|
+
return [h1, h2]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 将两个 32 位整数转为 base62 字符串,取前 8 位
|
|
25
|
+
function toBase62(h1, h2) {
|
|
26
|
+
const num = (BigInt(h1) << 32n) | BigInt(h2)
|
|
27
|
+
let n = num
|
|
28
|
+
let result = ''
|
|
29
|
+
while (n > 0n && result.length < 12) {
|
|
30
|
+
result = BASE62[Number(n % 62n)] + result
|
|
31
|
+
n = n / 62n
|
|
32
|
+
}
|
|
33
|
+
return result.padStart(8, '0').slice(0, 8)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 根据文档 key 生成 8 位 base62 短 hash(同步、确定性、零依赖)
|
|
37
|
+
export function docHash(key) {
|
|
38
|
+
const [h1, h2] = fnv1a64(key)
|
|
39
|
+
return toBase62(h1, h2)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 解析相对路径,基于当前文档 key 计算目标 key
|
|
43
|
+
export function resolveDocKey(href, currentDocKey) {
|
|
44
|
+
const currentParts = currentDocKey.split('/')
|
|
45
|
+
currentParts.pop() // 去掉当前文件名
|
|
46
|
+
const linkParts = href.replace(/\.md$/, '').split('/')
|
|
47
|
+
const resolved = [...currentParts]
|
|
48
|
+
for (const part of linkParts) {
|
|
49
|
+
if (part === '.' || part === '') continue
|
|
50
|
+
if (part === '..') { resolved.pop(); continue }
|
|
51
|
+
resolved.push(part)
|
|
52
|
+
}
|
|
53
|
+
return resolved.join('/')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 在文档树中查找文档
|
|
57
|
+
export function findDocInTree(items, key) {
|
|
58
|
+
for (const item of items) {
|
|
59
|
+
if (item.type === 'file' && item.key === key) return item
|
|
60
|
+
if (item.type === 'folder' && item.children) {
|
|
61
|
+
const found = findDocInTree(item.children, key)
|
|
62
|
+
if (found) return found
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return null
|
|
66
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { ref, computed, nextTick } from 'vue'
|
|
2
|
+
import { getDocsList } from '../api/docs.js'
|
|
3
|
+
import { useMarkdown } from './useMarkdown.js'
|
|
4
|
+
import { useSearch } from './useSearch.js'
|
|
5
|
+
import { useScroll } from './useScroll.js'
|
|
6
|
+
import { useMobile } from './useMobile.js'
|
|
7
|
+
import { findDoc, findFirstDoc, findDocByHash, expandParents, flattenDocsList, expandAll, collapseAll } from './useDocTree.js'
|
|
8
|
+
|
|
9
|
+
// 等待内容区域图片加载完成
|
|
10
|
+
async function waitForContentImages(timeoutMs = 3000) {
|
|
11
|
+
const images = document.querySelectorAll('.markdown-content img')
|
|
12
|
+
const pending = [...images].filter(img => !img.complete)
|
|
13
|
+
if (!pending.length) return
|
|
14
|
+
await Promise.race([
|
|
15
|
+
Promise.all(pending.map(img => new Promise(r => { img.onload = img.onerror = r }))),
|
|
16
|
+
new Promise(r => setTimeout(r, timeoutMs))
|
|
17
|
+
])
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function useDocManager() {
|
|
21
|
+
// 文档状态
|
|
22
|
+
const docsList = ref([])
|
|
23
|
+
const currentDoc = ref('')
|
|
24
|
+
// 如果 URL 有路径,说明是刷新已有文档页面,初始不显示欢迎页,避免闪烁
|
|
25
|
+
const hasInitialPath = window.location.pathname.replace(/^\//, '') !== ''
|
|
26
|
+
const showWelcome = ref(!hasInitialPath)
|
|
27
|
+
|
|
28
|
+
// composables
|
|
29
|
+
const { htmlContent, tocItems, renderMarkdown, docHash } = useMarkdown()
|
|
30
|
+
const { buildIndex } = useSearch()
|
|
31
|
+
const {
|
|
32
|
+
scrollProgress, showBackToTop, activeHeading,
|
|
33
|
+
handleScroll: _handleScroll,
|
|
34
|
+
scrollToHeading: _scrollToHeading,
|
|
35
|
+
scrollToTop
|
|
36
|
+
} = useScroll()
|
|
37
|
+
const { isMobile, mobileDrawerOpen } = useMobile()
|
|
38
|
+
|
|
39
|
+
// 滚动处理,同步锚点到 URL
|
|
40
|
+
function handleScroll(e) {
|
|
41
|
+
_handleScroll(e)
|
|
42
|
+
if (activeHeading.value && currentDoc.value) {
|
|
43
|
+
history.replaceState(null, '', `/${docHash(currentDoc.value)}#${activeHeading.value}`)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// push: true 表示用户主动点击锚点,产生可回退的历史条目
|
|
48
|
+
function scrollToHeading(id, { push = false } = {}) {
|
|
49
|
+
_scrollToHeading(id)
|
|
50
|
+
if (currentDoc.value) {
|
|
51
|
+
const url = `/${docHash(currentDoc.value)}#${id}`
|
|
52
|
+
if (push) {
|
|
53
|
+
history.pushState(null, '', url)
|
|
54
|
+
} else {
|
|
55
|
+
history.replaceState(null, '', url)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 加载文档列表
|
|
61
|
+
async function loadDocsList() {
|
|
62
|
+
docsList.value = await getDocsList()
|
|
63
|
+
buildIndex(docsList.value)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 回到欢迎页
|
|
67
|
+
function goHome() {
|
|
68
|
+
currentDoc.value = ''
|
|
69
|
+
showWelcome.value = true
|
|
70
|
+
htmlContent.value = ''
|
|
71
|
+
tocItems.value = []
|
|
72
|
+
history.pushState(null, '', '/')
|
|
73
|
+
if (isMobile.value) mobileDrawerOpen.value = false
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 加载文档
|
|
77
|
+
async function loadDoc(key, { replace = false, anchor = '' } = {}) {
|
|
78
|
+
currentDoc.value = key
|
|
79
|
+
showWelcome.value = false
|
|
80
|
+
const hash = docHash(key)
|
|
81
|
+
const url = anchor ? `/${hash}#${anchor}` : `/${hash}`
|
|
82
|
+
if (replace) {
|
|
83
|
+
history.replaceState(null, '', url)
|
|
84
|
+
} else {
|
|
85
|
+
history.pushState(null, '', url)
|
|
86
|
+
}
|
|
87
|
+
const doc = findDoc(docsList.value, key)
|
|
88
|
+
if (!doc) return
|
|
89
|
+
try {
|
|
90
|
+
const response = await fetch(doc.path)
|
|
91
|
+
if (response.ok) {
|
|
92
|
+
const content = await response.text()
|
|
93
|
+
await renderMarkdown(content, key, docsList.value)
|
|
94
|
+
const contentEl = document.querySelector('.content')
|
|
95
|
+
if (contentEl) contentEl.scrollTop = 0
|
|
96
|
+
}
|
|
97
|
+
} catch (error) {
|
|
98
|
+
console.error('加载文档失败:', error)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 加载第一篇文档
|
|
103
|
+
function loadFirstDoc() {
|
|
104
|
+
const first = findFirstDoc(docsList.value)
|
|
105
|
+
if (first) loadDoc(first.key)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 选择文档(移动端自动关闭抽屉)
|
|
109
|
+
function handleDocSelect(key) {
|
|
110
|
+
loadDoc(key)
|
|
111
|
+
if (isMobile.value) mobileDrawerOpen.value = false
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 文件夹操作
|
|
115
|
+
function toggleFolder(item) { item.expanded = !item.expanded }
|
|
116
|
+
function onExpandAll() { expandAll(docsList.value) }
|
|
117
|
+
function onCollapseAll() { collapseAll(docsList.value) }
|
|
118
|
+
|
|
119
|
+
// 内容区点击处理(链接跳转 + 图片放大)
|
|
120
|
+
function handleContentClick(event, { onZoom }) {
|
|
121
|
+
const target = event.target
|
|
122
|
+
const link = target.closest('a')
|
|
123
|
+
if (link) {
|
|
124
|
+
const docKey = link.dataset.docKey
|
|
125
|
+
if (docKey) {
|
|
126
|
+
event.preventDefault()
|
|
127
|
+
const anchor = link.dataset.anchor || ''
|
|
128
|
+
expandParents(docsList.value, docKey)
|
|
129
|
+
loadDoc(docKey).then(async () => {
|
|
130
|
+
if (anchor) { await nextTick(); await waitForContentImages(); scrollToHeading(anchor) }
|
|
131
|
+
})
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
if (link.dataset.anchor && !docKey) {
|
|
135
|
+
event.preventDefault()
|
|
136
|
+
scrollToHeading(link.dataset.anchor, { push: true })
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
// 图片放大
|
|
142
|
+
if (target.tagName === 'IMG' && target.classList.contains('zoomable-image')) {
|
|
143
|
+
onZoom(`<img src="${target.src}" alt="${target.alt || ''}" style="max-width: 100%; height: auto;" />`)
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
// Mermaid 图表放大
|
|
147
|
+
const mermaidEl = target.closest('.mermaid')
|
|
148
|
+
if (mermaidEl && mermaidEl.classList.contains('zoomable-image')) {
|
|
149
|
+
onZoom(mermaidEl.innerHTML)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 上一篇/下一篇
|
|
154
|
+
const prevDoc = computed(() => {
|
|
155
|
+
if (!currentDoc.value) return null
|
|
156
|
+
const flat = flattenDocsList(docsList.value)
|
|
157
|
+
const idx = flat.findIndex(d => d.key === currentDoc.value)
|
|
158
|
+
return idx > 0 ? flat[idx - 1] : null
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const nextDoc = computed(() => {
|
|
162
|
+
if (!currentDoc.value) return null
|
|
163
|
+
const flat = flattenDocsList(docsList.value)
|
|
164
|
+
const idx = flat.findIndex(d => d.key === currentDoc.value)
|
|
165
|
+
return idx >= 0 && idx < flat.length - 1 ? flat[idx + 1] : null
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// 搜索结果选中
|
|
169
|
+
function handleSearchSelect(key) {
|
|
170
|
+
expandParents(docsList.value, key)
|
|
171
|
+
loadDoc(key)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// URL 路由(popstate / 初始加载)
|
|
175
|
+
async function loadFromUrl() {
|
|
176
|
+
const pathname = window.location.pathname.replace(/^\//, '')
|
|
177
|
+
const anchor = window.location.hash.replace('#', '')
|
|
178
|
+
if (!pathname) {
|
|
179
|
+
if (currentDoc.value) {
|
|
180
|
+
// 从文档页回退到首页
|
|
181
|
+
goHome()
|
|
182
|
+
} else if (docsList.value.length === 0) {
|
|
183
|
+
showWelcome.value = false
|
|
184
|
+
renderMarkdown('# 当前目录没有 Markdown 文档\n\n请在当前目录下添加 `.md` 文件,然后刷新页面。')
|
|
185
|
+
}
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
const doc = findDocByHash(docsList.value, pathname, docHash)
|
|
189
|
+
if (!doc) return
|
|
190
|
+
// 同一文档内的锚点变化,只需滚动,无需重新加载
|
|
191
|
+
if (doc.key === currentDoc.value) {
|
|
192
|
+
if (anchor) {
|
|
193
|
+
await nextTick()
|
|
194
|
+
_scrollToHeading(decodeURIComponent(anchor))
|
|
195
|
+
} else {
|
|
196
|
+
const contentEl = document.querySelector('.content')
|
|
197
|
+
if (contentEl) contentEl.scrollTo({ top: 0, behavior: 'smooth' })
|
|
198
|
+
}
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
expandParents(docsList.value, doc.key)
|
|
202
|
+
await loadDoc(doc.key, { replace: true, anchor: anchor ? decodeURIComponent(anchor) : '' })
|
|
203
|
+
if (anchor) { await nextTick(); await waitForContentImages(); _scrollToHeading(decodeURIComponent(anchor)) }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
// 状态
|
|
208
|
+
docsList,
|
|
209
|
+
currentDoc,
|
|
210
|
+
showWelcome,
|
|
211
|
+
htmlContent,
|
|
212
|
+
tocItems,
|
|
213
|
+
// 滚动
|
|
214
|
+
scrollProgress,
|
|
215
|
+
showBackToTop,
|
|
216
|
+
activeHeading,
|
|
217
|
+
handleScroll,
|
|
218
|
+
scrollToHeading,
|
|
219
|
+
scrollToTop,
|
|
220
|
+
// 文档操作
|
|
221
|
+
loadDocsList,
|
|
222
|
+
loadFromUrl,
|
|
223
|
+
goHome,
|
|
224
|
+
loadDoc,
|
|
225
|
+
loadFirstDoc,
|
|
226
|
+
handleDocSelect,
|
|
227
|
+
handleContentClick,
|
|
228
|
+
handleSearchSelect,
|
|
229
|
+
// 文件夹操作
|
|
230
|
+
toggleFolder,
|
|
231
|
+
onExpandAll,
|
|
232
|
+
onCollapseAll,
|
|
233
|
+
// 导航
|
|
234
|
+
prevDoc,
|
|
235
|
+
nextDoc,
|
|
236
|
+
// 工具
|
|
237
|
+
docHash
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// 文档树操作:查找、展开、扁平化等纯逻辑
|
|
2
|
+
|
|
3
|
+
// 在文档树中查找文档
|
|
4
|
+
export function findDoc(items, key) {
|
|
5
|
+
for (const item of items) {
|
|
6
|
+
if (item.type === 'file' && item.key === key) return item
|
|
7
|
+
if (item.type === 'folder' && item.children) {
|
|
8
|
+
const found = findDoc(item.children, key)
|
|
9
|
+
if (found) return found
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// 查找第一个文档
|
|
16
|
+
export function findFirstDoc(items) {
|
|
17
|
+
for (const item of items) {
|
|
18
|
+
if (item.type === 'file') return item
|
|
19
|
+
if (item.type === 'folder' && item.children) {
|
|
20
|
+
const found = findFirstDoc(item.children)
|
|
21
|
+
if (found) return found
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 根据 hash 查找文档
|
|
28
|
+
export function findDocByHash(items, hash, docHash) {
|
|
29
|
+
for (const item of items) {
|
|
30
|
+
if (item.type === 'file' && docHash(item.key) === hash) return item
|
|
31
|
+
if (item.type === 'folder' && item.children) {
|
|
32
|
+
const found = findDocByHash(item.children, hash, docHash)
|
|
33
|
+
if (found) return found
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 展开文档所在的所有父级文件夹
|
|
40
|
+
export function expandParents(items, targetKey) {
|
|
41
|
+
for (const item of items) {
|
|
42
|
+
if (item.type === 'file' && item.key === targetKey) return true
|
|
43
|
+
if (item.type === 'folder' && item.children) {
|
|
44
|
+
if (expandParents(item.children, targetKey)) {
|
|
45
|
+
item.expanded = true
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 扁平化文档树,按顺序提取所有文件节点
|
|
54
|
+
export function flattenDocsList(items, result = []) {
|
|
55
|
+
for (const item of items) {
|
|
56
|
+
if (item.type === 'file') result.push(item)
|
|
57
|
+
if (item.type === 'folder' && item.children) flattenDocsList(item.children, result)
|
|
58
|
+
}
|
|
59
|
+
return result
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 全部展开
|
|
63
|
+
export function expandAll(items) {
|
|
64
|
+
items.forEach(item => {
|
|
65
|
+
if (item.type === 'folder') {
|
|
66
|
+
item.expanded = true
|
|
67
|
+
if (item.children) expandAll(item.children)
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 全部收起
|
|
73
|
+
export function collapseAll(items) {
|
|
74
|
+
items.forEach(item => {
|
|
75
|
+
if (item.type === 'folder') {
|
|
76
|
+
item.expanded = false
|
|
77
|
+
if (item.children) collapseAll(item.children)
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Frontmatter 解析 & 阅读时间计算
|
|
2
|
+
|
|
3
|
+
// 解析 YAML frontmatter(轻量实现,不依赖 Node.js)
|
|
4
|
+
// 支持 title / description / order / hidden 字段
|
|
5
|
+
export function parseFrontmatter(markdown) {
|
|
6
|
+
const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/)
|
|
7
|
+
if (!match) return { data: {}, content: markdown }
|
|
8
|
+
const yamlStr = match[1]
|
|
9
|
+
const data = {}
|
|
10
|
+
for (const line of yamlStr.split('\n')) {
|
|
11
|
+
const m = line.match(/^(\w+)\s*:\s*(.+)$/)
|
|
12
|
+
if (!m) continue
|
|
13
|
+
let val = m[2].trim()
|
|
14
|
+
// 布尔值
|
|
15
|
+
if (val === 'true') val = true
|
|
16
|
+
else if (val === 'false') val = false
|
|
17
|
+
// 数字
|
|
18
|
+
else if (/^\d+$/.test(val)) val = parseInt(val)
|
|
19
|
+
// 去掉引号
|
|
20
|
+
else val = val.replace(/^['"]|['"]$/g, '')
|
|
21
|
+
data[m[1]] = val
|
|
22
|
+
}
|
|
23
|
+
return { data, content: markdown.slice(match[0].length) }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 计算阅读时间(中文 400 字/分钟,英文 200 词/分钟)
|
|
27
|
+
export function calcReadingTime(markdown) {
|
|
28
|
+
// 去掉 frontmatter、代码块、HTML 标签
|
|
29
|
+
const clean = markdown
|
|
30
|
+
.replace(/^---[\s\S]*?---\n?/, '')
|
|
31
|
+
.replace(/```[\s\S]*?```/g, '')
|
|
32
|
+
.replace(/<[^>]+>/g, '')
|
|
33
|
+
.replace(/[#*_~`>\-|[\]()!]/g, '')
|
|
34
|
+
// 中文字符数
|
|
35
|
+
const cnChars = (clean.match(/[\u4e00-\u9fff]/g) || []).length
|
|
36
|
+
// 英文单词数
|
|
37
|
+
const enWords = clean.replace(/[\u4e00-\u9fff]/g, '').split(/\s+/).filter(w => w.length > 0).length
|
|
38
|
+
const totalChars = cnChars + enWords
|
|
39
|
+
const minutes = Math.ceil(cnChars / 400 + enWords / 200)
|
|
40
|
+
return { totalChars, minutes: Math.max(1, minutes) }
|
|
41
|
+
}
|