md2ui 1.0.7 → 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.
@@ -1,128 +1,350 @@
1
1
  import { ref, nextTick } from 'vue'
2
2
  import { marked } from 'marked'
3
3
  import mermaid from 'mermaid'
4
+ import GithubSlugger from 'github-slugger'
5
+ import hljs from 'highlight.js'
6
+ import { parseFrontmatter, calcReadingTime } from './useFrontmatter.js'
7
+ import { docHash, resolveDocKey, findDocInTree } from './useDocHash.js'
4
8
 
5
- // 初始化 Mermaid - 使用 neutral 主题
9
+ // 初始化 Mermaid 基于 base 主题自定义柔和蓝紫色调
6
10
  mermaid.initialize({
7
11
  startOnLoad: false,
8
- theme: 'neutral',
9
- securityLevel: 'loose'
12
+ theme: 'base',
13
+ // Mermaid 内置主题有这几个:default、neutral、dark、forest、base。
14
+ securityLevel: 'loose',
15
+ themeVariables: {
16
+ // 基础色调
17
+ primaryColor: '#e8eaf6',
18
+ primaryTextColor: '#37474f',
19
+ primaryBorderColor: '#7986cb',
20
+ // 线条与标签
21
+ lineColor: '#90a4ae',
22
+ textColor: '#455a64',
23
+ // 次要 / 第三色
24
+ secondaryColor: '#f3e5f5',
25
+ secondaryBorderColor: '#ba68c8',
26
+ secondaryTextColor: '#4a148c',
27
+ tertiaryColor: '#e0f7fa',
28
+ tertiaryBorderColor: '#4dd0e1',
29
+ tertiaryTextColor: '#006064',
30
+ // 字体
31
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
32
+ fontSize: '14px',
33
+ // 节点样式
34
+ nodeBorder: '#7986cb',
35
+ nodeTextColor: '#37474f',
36
+ // 序列图
37
+ actorBkg: '#e8eaf6',
38
+ actorBorder: '#7986cb',
39
+ actorTextColor: '#37474f',
40
+ signalColor: '#5c6bc0',
41
+ signalTextColor: '#37474f',
42
+ // 甘特图
43
+ sectionBkgColor: '#e8eaf6',
44
+ altSectionBkgColor: '#f3e5f5',
45
+ taskBkgColor: '#7986cb',
46
+ taskTextColor: '#ffffff',
47
+ activeTaskBkgColor: '#5c6bc0',
48
+ doneTaskBkgColor: '#9fa8da',
49
+ // 饼图
50
+ pie1: '#7986cb',
51
+ pie2: '#ba68c8',
52
+ pie3: '#4dd0e1',
53
+ pie4: '#ffb74d',
54
+ pie5: '#a1887f',
55
+ // 类图
56
+ classText: '#37474f',
57
+ // 状态图
58
+ labelColor: '#37474f',
59
+ // 背景
60
+ mainBkg: '#e8eaf6',
61
+ nodeBkg: '#e8eaf6',
62
+ background: '#ffffff',
63
+ }
10
64
  })
11
65
 
12
- // 自定义 marked 渲染器,处理 Mermaid 代码块
13
- const renderer = new marked.Renderer()
14
- const originalCodeRenderer = renderer.code.bind(renderer)
66
+ // 创建自定义渲染器,处理链接、标题锚点和 Mermaid
67
+ function createRenderer(currentDocKey, docsList) {
68
+ const renderer = new marked.Renderer()
69
+ const slugger = new GithubSlugger()
15
70
 
16
- renderer.code = function(code, language) {
17
- if (language === 'mermaid') {
18
- const id = 'mermaid-' + Math.random().toString(36).substr(2, 9)
19
- return `<div class="mermaid" id="${id}">${code}</div>`
71
+ // 标题渲染:使用 github-slugger 生成语义化锚点 ID
72
+ renderer.heading = function(text, level) {
73
+ const id = slugger.slug(text)
74
+ const linkIcon = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`
75
+ return `<h${level} id="${id}"><a class="heading-anchor" href="#${id}" data-anchor="${id}" aria-hidden="true">${linkIcon}</a>${text}</h${level}>\n`
20
76
  }
21
- return originalCodeRenderer(code, language)
22
- }
23
77
 
24
- marked.setOptions({
25
- renderer,
26
- breaks: true,
27
- gfm: true,
28
- headerIds: true,
29
- mangle: false
30
- })
78
+ // 代码块渲染:Mermaid 图表 / 语法高亮 + 复制按钮
79
+ renderer.code = function(code, language) {
80
+ if (language === 'mermaid') {
81
+ const id = 'mermaid-' + Math.random().toString(36).substr(2, 9)
82
+ return `<div class="mermaid" id="${id}">${code}</div>`
83
+ }
84
+ // 高亮代码
85
+ let highlighted
86
+ if (language && hljs.getLanguage(language)) {
87
+ highlighted = hljs.highlight(code, { language }).value
88
+ } else {
89
+ highlighted = hljs.highlightAuto(code).value
90
+ }
31
91
 
32
- export function useMarkdown() {
33
- const htmlContent = ref('')
34
- const tocItems = ref([])
92
+ // 生成行号(按换行拆分)
93
+ const lines = code.split('\n')
94
+ const lineCount = lines.length
95
+ const lineNums = lines.map((_, i) => `<span class="line-num">${i + 1}</span>`).join('')
35
96
 
36
- // 渲染 Markdown
37
- async function renderMarkdown(markdown) {
38
- htmlContent.value = marked.parse(markdown)
39
- await renderMermaid()
40
- wrapTables()
41
- addImageZoomHandlers()
42
- extractTOC()
43
- }
97
+ const langLabel = (language || '').toUpperCase()
98
+ // 切换行号按钮(列表图标)
99
+ const toggleLineNumIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>`
100
+ // 切换高亮按钮(</> 文本图标)
101
+ const toggleHighlightIcon = `<span class="code-icon-text">&lt;/&gt;</span>`
102
+ // 复制按钮图标
103
+ const copyIcon = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`
44
104
 
45
- // 为表格添加滚动容器
46
- function wrapTables() {
47
- nextTick(() => {
48
- const tables = document.querySelectorAll('.markdown-content table')
49
- tables.forEach(table => {
50
- // 检查是否已经被包裹
51
- if (!table.parentElement.classList.contains('table-wrapper')) {
52
- const wrapper = document.createElement('div')
53
- wrapper.className = 'table-wrapper'
54
- table.parentNode.insertBefore(wrapper, table)
55
- wrapper.appendChild(table)
56
- }
57
- })
58
- })
105
+ return `<div class="code-block-wrapper" data-raw-code="${encodeURIComponent(code)}" data-lang="${language || ''}">
106
+ <div class="code-block-header">
107
+ <span class="code-lang-label">${langLabel}</span>
108
+ <div class="code-block-actions">
109
+ <button class="code-action-btn toggle-line-num-btn active" data-tooltip="隐藏行号">${toggleLineNumIcon}</button>
110
+ <button class="code-action-btn toggle-highlight-btn active" data-tooltip="关闭高亮">${toggleHighlightIcon}</button>
111
+ <button class="code-action-btn copy-code-btn" data-tooltip="复制代码">${copyIcon}<span class="copy-text">复制</span></button>
112
+ </div>
113
+ </div>
114
+ <div class="code-block-body">
115
+ <div class="line-numbers" aria-hidden="true">${lineNums}</div>
116
+ <pre><code class="hljs${language ? ` language-${language}` : ''}">${highlighted}</code></pre>
117
+ </div>
118
+ </div>`
59
119
  }
60
120
 
61
- // 渲染 Mermaid 图表
62
- async function renderMermaid() {
63
- await nextTick()
64
- const mermaidElements = document.querySelectorAll('.mermaid')
65
-
66
- for (const element of mermaidElements) {
67
- try {
68
- const id = element.id
69
- const code = element.textContent
70
- const { svg } = await mermaid.render(id + '-svg', code)
71
- element.innerHTML = svg
72
-
73
- // 为Mermaid图表添加可点击样式
74
- element.classList.add('zoomable-image')
75
- element.style.cursor = 'zoom-in'
76
- element.title = '点击放大查看'
77
- } catch (error) {
78
- console.error('Mermaid 渲染失败:', error)
79
- element.innerHTML = `<pre class="mermaid-error">图表渲染失败\n${error.message}</pre>`
121
+ // 链接渲染:站内/站外分类处理
122
+ renderer.link = function(href, title, text) {
123
+ const decoded = decodeURIComponent(href || '')
124
+ const titleAttr = title ? ` title="${title}"` : ''
125
+
126
+ // 站外链接
127
+ if (/^(https?|mailto|tel):/.test(decoded)) {
128
+ return `<a href="${href}"${titleAttr} target="_blank" rel="noopener">${text}</a>`
129
+ }
130
+ // 站内纯锚点:# 后的内容已是 slug 格式,无需再次 slug(避免计数器追加后缀)
131
+ if (decoded.startsWith('#')) {
132
+ const anchor = decoded.slice(1)
133
+ return `<a href="javascript:void(0)" data-anchor="${anchor}"${titleAttr}>${text}</a>`
134
+ }
135
+ // 站内 .md 文档链接
136
+ if (decoded.endsWith('.md') || decoded.includes('.md#')) {
137
+ const [mdPath, anchor] = decoded.includes('#') ? decoded.split('#') : [decoded, '']
138
+ const targetKey = resolveDocKey(mdPath, currentDocKey)
139
+ const doc = findDocInTree(docsList, targetKey)
140
+ if (doc) {
141
+ const hash = docHash(doc.key)
142
+ // anchor 已是 slug 格式,无需再次 slug
143
+ const url = anchor ? `/${hash}#${anchor}` : `/${hash}`
144
+ return `<a href="${url}" data-doc-key="${doc.key}"${anchor ? ` data-anchor="${anchor}"` : ''}${titleAttr}>${text}</a>`
80
145
  }
146
+ return `<a href="javascript:void(0)" class="broken-link" title="文档未找到: ${decoded}">${text}</a>`
81
147
  }
148
+ // 其他相对链接
149
+ return `<a href="${href}"${titleAttr} target="_blank" rel="noopener">${text}</a>`
82
150
  }
83
151
 
84
- // 为图片和Mermaid图表添加放大功能
85
- function addImageZoomHandlers() {
86
- nextTick(() => {
87
- // 为所有图片添加放大功能
88
- const images = document.querySelectorAll('.markdown-content img')
89
- images.forEach(img => {
90
- img.classList.add('zoomable-image')
91
- img.style.cursor = 'zoom-in'
92
- img.title = '点击放大查看'
152
+ return renderer
153
+ }
154
+
155
+
156
+ // ---- 后处理器 ----
157
+
158
+ // 渲染 Mermaid 图表
159
+ async function renderMermaid() {
160
+ await nextTick()
161
+ const mermaidElements = document.querySelectorAll('.mermaid')
162
+ for (const element of mermaidElements) {
163
+ try {
164
+ const id = element.id
165
+ const code = element.textContent
166
+ const { svg } = await mermaid.render(id + '-svg', code)
167
+ element.innerHTML = svg
168
+ element.classList.add('zoomable-image')
169
+ element.style.cursor = 'zoom-in'
170
+ element.title = '点击放大查看'
171
+ } catch (error) {
172
+ console.error('Mermaid 渲染失败:', error)
173
+ // 清理 Mermaid 渲染失败时残留在 DOM 中的错误容器
174
+ const errorEl = document.getElementById(element.id + '-svg')
175
+ if (errorEl) errorEl.remove()
176
+ element.innerHTML = `<pre class="mermaid-error">图表渲染失败\n${error.message}</pre>`
177
+ }
178
+ }
179
+ }
180
+
181
+ // 为表格添加滚动容器
182
+ function wrapTables() {
183
+ nextTick(() => {
184
+ const tables = document.querySelectorAll('.markdown-content table')
185
+ tables.forEach(table => {
186
+ if (!table.parentElement.classList.contains('table-wrapper')) {
187
+ const wrapper = document.createElement('div')
188
+ wrapper.className = 'table-wrapper'
189
+ table.parentNode.insertBefore(wrapper, table)
190
+ wrapper.appendChild(table)
191
+ }
192
+ })
193
+ })
194
+ }
195
+
196
+ // 为图片添加放大功能
197
+ function addImageZoomHandlers() {
198
+ nextTick(() => {
199
+ const images = document.querySelectorAll('.markdown-content img')
200
+ images.forEach(img => {
201
+ img.classList.add('zoomable-image')
202
+ img.style.cursor = 'zoom-in'
203
+ img.title = '点击放大查看'
204
+ })
205
+ })
206
+ }
207
+
208
+ // 为代码块添加交互事件(复制、切换行号、切换高亮)
209
+ function addCodeBlockHandlers() {
210
+ nextTick(() => {
211
+ // 复制按钮
212
+ document.querySelectorAll('.copy-code-btn').forEach(btn => {
213
+ btn.addEventListener('click', async () => {
214
+ const wrapper = btn.closest('.code-block-wrapper')
215
+ const code = wrapper.querySelector('code')
216
+ if (!code) return
217
+ try {
218
+ await navigator.clipboard.writeText(code.textContent)
219
+ const textEl = btn.querySelector('.copy-text')
220
+ textEl.textContent = '已复制'
221
+ btn.classList.add('copied')
222
+ setTimeout(() => {
223
+ textEl.textContent = '复制'
224
+ btn.classList.remove('copied')
225
+ }, 2000)
226
+ } catch {
227
+ const range = document.createRange()
228
+ range.selectNodeContents(code)
229
+ window.getSelection().removeAllRanges()
230
+ window.getSelection().addRange(range)
231
+ }
232
+ })
233
+ })
234
+
235
+ // 切换行号
236
+ document.querySelectorAll('.toggle-line-num-btn').forEach(btn => {
237
+ btn.addEventListener('click', () => {
238
+ const wrapper = btn.closest('.code-block-wrapper')
239
+ const lineNumbers = wrapper.querySelector('.line-numbers')
240
+ if (!lineNumbers) return
241
+ btn.classList.toggle('active')
242
+ lineNumbers.classList.toggle('hidden')
243
+ btn.dataset.tooltip = btn.classList.contains('active') ? '隐藏行号' : '显示行号'
93
244
  })
94
245
  })
95
- }
96
246
 
97
- // 提取文档大纲
98
- function extractTOC() {
99
- tocItems.value = []
100
-
101
- nextTick(() => {
102
- const headings = document.querySelectorAll('.markdown-content h1, .markdown-content h2, .markdown-content h3, .markdown-content h4, .markdown-content h5, .markdown-content h6')
103
-
104
- headings.forEach((heading, index) => {
105
- const level = parseInt(heading.tagName.substring(1))
106
- const text = heading.textContent.trim()
107
- const id = heading.id || `heading-${index}`
108
-
109
- if (!heading.id) {
110
- heading.id = id
247
+ // 切换语法高亮
248
+ document.querySelectorAll('.toggle-highlight-btn').forEach(btn => {
249
+ btn.addEventListener('click', () => {
250
+ const wrapper = btn.closest('.code-block-wrapper')
251
+ const codeEl = wrapper.querySelector('code')
252
+ if (!codeEl) return
253
+ const rawCode = decodeURIComponent(wrapper.dataset.rawCode || '')
254
+ const lang = wrapper.dataset.lang || ''
255
+ const isHighlighted = btn.classList.contains('active')
256
+
257
+ if (isHighlighted) {
258
+ // 关闭高亮:显示纯文本
259
+ codeEl.textContent = rawCode
260
+ codeEl.className = 'code-plain'
261
+ btn.classList.remove('active')
262
+ btn.dataset.tooltip = '开启高亮'
263
+ } else {
264
+ // 开启高亮:重新渲染
265
+ let highlighted
266
+ if (lang && hljs.getLanguage(lang)) {
267
+ highlighted = hljs.highlight(rawCode, { language: lang }).value
268
+ } else {
269
+ highlighted = hljs.highlightAuto(rawCode).value
270
+ }
271
+ codeEl.innerHTML = highlighted
272
+ codeEl.className = `hljs${lang ? ` language-${lang}` : ''}`
273
+ btn.classList.add('active')
274
+ btn.dataset.tooltip = '关闭高亮'
111
275
  }
112
-
113
- tocItems.value.push({
114
- id,
115
- text,
116
- level
117
- })
118
276
  })
119
277
  })
278
+ })
279
+ }
280
+
281
+ // 提取文档大纲
282
+ function extractTOC(tocItems) {
283
+ tocItems.value = []
284
+ nextTick(() => {
285
+ const headings = document.querySelectorAll('.markdown-content h1, .markdown-content h2, .markdown-content h3, .markdown-content h4, .markdown-content h5, .markdown-content h6')
286
+ headings.forEach(heading => {
287
+ const level = parseInt(heading.tagName.substring(1))
288
+ const clone = heading.cloneNode(true)
289
+ clone.querySelectorAll('.heading-anchor').forEach(a => a.remove())
290
+ const text = clone.textContent.trim()
291
+ const id = heading.id
292
+ if (id) {
293
+ tocItems.value.push({ id, text, level })
294
+ }
295
+ })
296
+ })
297
+ }
298
+
299
+ // ---- 主 composable ----
300
+
301
+ export function useMarkdown() {
302
+ const htmlContent = ref('')
303
+ const tocItems = ref([])
304
+
305
+ // 渲染 Markdown,传入当前文档 key 和文档列表用于链接改写
306
+ async function renderMarkdown(markdown, currentDocKey = '', docsList = []) {
307
+ const { data: frontmatter, content } = parseFrontmatter(markdown)
308
+ const renderer = createRenderer(currentDocKey, docsList)
309
+ marked.setOptions({
310
+ renderer,
311
+ breaks: true,
312
+ gfm: true,
313
+ headerIds: false,
314
+ mangle: false
315
+ })
316
+ htmlContent.value = marked.parse(content)
317
+
318
+ // frontmatter.title 覆盖第一个 h1
319
+ if (frontmatter.title) {
320
+ htmlContent.value = htmlContent.value.replace(
321
+ /<h1[^>]*>[\s\S]*?<\/h1>/,
322
+ `<h1 id="${new GithubSlugger().slug(frontmatter.title)}">${frontmatter.title}</h1>`
323
+ )
324
+ }
325
+
326
+ // 在第一个 h1 后插入阅读元信息
327
+ const { totalChars, minutes } = calcReadingTime(content)
328
+ if (totalChars > 0) {
329
+ const metaParts = [`${totalChars} 字`, `约 ${minutes} 分钟`]
330
+ if (frontmatter.description) metaParts.push(frontmatter.description)
331
+ const metaHtml = `<div class="doc-meta">${metaParts.map(p => `<span class="doc-meta-item">${p}</span>`).join('<span class="doc-meta-sep">·</span>')}</div>`
332
+ htmlContent.value = htmlContent.value.replace(/(<\/h1>)/, `$1\n${metaHtml}`)
333
+ }
334
+
335
+ // 后处理
336
+ await renderMermaid()
337
+ wrapTables()
338
+ addImageZoomHandlers()
339
+ addCodeBlockHandlers()
340
+ extractTOC(tocItems)
120
341
  }
121
342
 
122
343
  return {
123
344
  htmlContent,
124
345
  tocItems,
125
346
  renderMarkdown,
126
- addImageZoomHandlers
347
+ addImageZoomHandlers,
348
+ docHash
127
349
  }
128
350
  }
@@ -0,0 +1,29 @@
1
+ import { ref } from 'vue'
2
+
3
+ const MOBILE_BREAKPOINT = 768
4
+
5
+ // 单例状态,确保多处调用共享同一份
6
+ const isMobile = ref(false)
7
+ const mobileDrawerOpen = ref(false)
8
+ const mobileTocOpen = ref(false)
9
+ let listenerAttached = false
10
+
11
+ // 移动端检测 & 抽屉/TOC 面板状态
12
+ export function useMobile() {
13
+ function checkMobile() {
14
+ isMobile.value = window.innerWidth <= MOBILE_BREAKPOINT
15
+ if (!isMobile.value) {
16
+ mobileDrawerOpen.value = false
17
+ mobileTocOpen.value = false
18
+ }
19
+ }
20
+
21
+ // 只绑定一次事件监听
22
+ if (!listenerAttached && typeof window !== 'undefined') {
23
+ checkMobile()
24
+ window.addEventListener('resize', checkMobile)
25
+ listenerAttached = true
26
+ }
27
+
28
+ return { isMobile, mobileDrawerOpen, mobileTocOpen }
29
+ }
@@ -1,9 +1,11 @@
1
1
  import { ref } from 'vue'
2
2
 
3
+ // 单例状态,确保多处调用共享同一份
4
+ const scrollProgress = ref(0)
5
+ const showBackToTop = ref(false)
6
+ const activeHeading = ref('')
7
+
3
8
  export function useScroll() {
4
- const scrollProgress = ref(0)
5
- const showBackToTop = ref(false)
6
- const activeHeading = ref('')
7
9
 
8
10
  // 监听滚动
9
11
  function handleScroll(e) {
@@ -43,18 +45,10 @@ export function useScroll() {
43
45
 
44
46
  // 滚动到指定标题
45
47
  function scrollToHeading(id) {
46
- const element = document.getElementById(id)
47
- const content = document.querySelector('.content')
48
-
49
- if (element && content) {
50
- const contentRect = content.getBoundingClientRect()
51
- const elementRect = element.getBoundingClientRect()
52
- const offsetTop = elementRect.top - contentRect.top + content.scrollTop
53
-
54
- content.scrollTo({
55
- top: offsetTop - 20,
56
- behavior: 'smooth'
57
- })
48
+ const el = document.getElementById(id)
49
+ if (el) {
50
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' })
51
+ activeHeading.value = id
58
52
  }
59
53
  }
60
54
 
@@ -0,0 +1,133 @@
1
+ import { ref } from 'vue'
2
+ import MiniSearch from 'minisearch'
3
+
4
+ // 搜索索引实例
5
+ let searchIndex = null
6
+ // 缓存文档列表引用,用于懒加载
7
+ let pendingDocsList = null
8
+
9
+ // 扁平化文档树,提取所有文件节点
10
+ function flattenDocs(items, result = []) {
11
+ for (const item of items) {
12
+ if (item.type === 'file') result.push(item)
13
+ if (item.type === 'folder' && item.children) {
14
+ flattenDocs(item.children, result)
15
+ }
16
+ }
17
+ return result
18
+ }
19
+
20
+ // 单例状态,确保多处调用共享同一份
21
+ const searchVisible = ref(false)
22
+ const searchQuery = ref('')
23
+ const searchResults = ref([])
24
+ const searchReady = ref(false)
25
+ const indexBuilding = ref(false)
26
+
27
+ export function useSearch() {
28
+
29
+ // 注册文档列表(不立即构建索引,等用户打开搜索时再构建)
30
+ function buildIndex(docsList) {
31
+ pendingDocsList = docsList
32
+ // 如果索引已存在,标记需要重建
33
+ if (searchIndex) {
34
+ searchIndex = null
35
+ searchReady.value = false
36
+ }
37
+ }
38
+
39
+ // 实际构建搜索索引
40
+ async function ensureIndex() {
41
+ if (searchReady.value || indexBuilding.value || !pendingDocsList) return
42
+ indexBuilding.value = true
43
+
44
+ const docs = flattenDocs(pendingDocsList)
45
+ const documents = []
46
+
47
+ for (const doc of docs) {
48
+ try {
49
+ const response = await fetch(doc.path)
50
+ if (response.ok) {
51
+ const content = await response.text()
52
+ documents.push({
53
+ id: doc.key,
54
+ title: doc.label,
55
+ content: content.replace(/^---[\s\S]*?---\n?/, ''), // 去掉 frontmatter
56
+ path: doc.path
57
+ })
58
+ }
59
+ } catch {
60
+ // 忽略加载失败的文档
61
+ }
62
+ }
63
+
64
+ searchIndex = new MiniSearch({
65
+ fields: ['title', 'content'],
66
+ storeFields: ['title'],
67
+ searchOptions: {
68
+ boost: { title: 3 },
69
+ fuzzy: 0.2,
70
+ prefix: true
71
+ },
72
+ // 中文分词:按标点、空格、换行分割
73
+ tokenize: (text) => {
74
+ const tokens = text.split(/[\s\n\r\t,.;:!?,。;:!?、()()【】\[\]{}""''""]+/)
75
+ .filter(t => t.length > 0)
76
+ // 对中文文本额外做 bigram 分词
77
+ const bigrams = []
78
+ for (const token of tokens) {
79
+ if (/[\u4e00-\u9fff]/.test(token) && token.length > 1) {
80
+ for (let i = 0; i < token.length - 1; i++) {
81
+ bigrams.push(token.slice(i, i + 2))
82
+ }
83
+ }
84
+ }
85
+ return [...tokens, ...bigrams]
86
+ }
87
+ })
88
+
89
+ searchIndex.addAll(documents)
90
+ searchReady.value = true
91
+ indexBuilding.value = false
92
+ }
93
+
94
+ // 执行搜索
95
+ function doSearch(query) {
96
+ searchQuery.value = query
97
+ if (!query.trim() || !searchIndex) {
98
+ searchResults.value = []
99
+ return
100
+ }
101
+ const results = searchIndex.search(query, { limit: 20 })
102
+ searchResults.value = results.map(r => ({
103
+ key: r.id,
104
+ title: r.title,
105
+ score: r.score
106
+ }))
107
+ }
108
+
109
+ // 打开搜索面板(触发懒加载索引构建)
110
+ async function openSearch() {
111
+ searchVisible.value = true
112
+ await ensureIndex()
113
+ }
114
+
115
+ // 关闭搜索面板
116
+ function closeSearch() {
117
+ searchVisible.value = false
118
+ searchQuery.value = ''
119
+ searchResults.value = []
120
+ }
121
+
122
+ return {
123
+ searchVisible,
124
+ searchQuery,
125
+ searchResults,
126
+ searchReady,
127
+ indexBuilding,
128
+ buildIndex,
129
+ doSearch,
130
+ openSearch,
131
+ closeSearch
132
+ }
133
+ }