md2ui 1.0.2 → 1.0.7

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 CHANGED
@@ -1,5 +1,7 @@
1
1
  # md2ui
2
2
 
3
+ [![GitHub](https://img.shields.io/badge/GitHub-devsneed%2Fmd2ui-blue?logo=github)](https://github.com/devsneed/md2ui)
4
+
3
5
  一个轻量级的文档渲染系统,将本地 Markdown 文档转换为美观的 HTML 页面。
4
6
 
5
7
  ## 核心特性
@@ -63,7 +65,7 @@ your-docs/
63
65
 
64
66
  ```bash
65
67
  # 克隆项目
66
- git clone https://github.com/user/md2ui.git
68
+ git clone https://github.com/devsneed/md2ui.git
67
69
  cd md2ui
68
70
 
69
71
  # 安装依赖
@@ -81,8 +83,8 @@ pnpm dev
81
83
  ### 本地测试 CLI
82
84
 
83
85
  ```bash
84
- # 链接到全局
85
- pnpm link --global
86
+ # 安装到全局
87
+ npm install -g .
86
88
 
87
89
  # 在任意目录测试
88
90
  cd /path/to/test/docs
package/bin/md2ui.js CHANGED
@@ -10,16 +10,19 @@ const __filename = fileURLToPath(import.meta.url)
10
10
  const __dirname = dirname(__filename)
11
11
  const pkgRoot = resolve(__dirname, '..')
12
12
 
13
+ // 导入共享配置
14
+ const { config } = await import(resolve(pkgRoot, 'src/config.js'))
15
+
13
16
  // 用户执行命令的目录
14
17
  const userDir = process.cwd()
15
18
 
16
19
  // 解析命令行参数
17
20
  const args = process.argv.slice(2)
18
- let port = 3000
21
+ let port = config.defaultPort
19
22
 
20
23
  for (let i = 0; i < args.length; i++) {
21
24
  if (args[i] === '-p' || args[i] === '--port') {
22
- port = parseInt(args[i + 1]) || 3000
25
+ port = parseInt(args[i + 1]) || config.defaultPort
23
26
  }
24
27
  }
25
28
 
@@ -70,7 +73,7 @@ function scanDocs(dir, basePath = '', level = 0) {
70
73
  order: match ? parseInt(match[1]) : 999,
71
74
  type: 'folder',
72
75
  level,
73
- expanded: true,
76
+ expanded: config.folderExpanded,
74
77
  children
75
78
  })
76
79
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "md2ui",
3
- "version": "1.0.2",
3
+ "version": "1.0.7",
4
4
  "type": "module",
5
5
  "description": "将本地 Markdown 文档转换为美观的 HTML 页面",
6
6
  "author": "",
package/src/App.vue CHANGED
@@ -2,7 +2,15 @@
2
2
  <div class="container">
3
3
  <aside v-if="!sidebarCollapsed" class="sidebar" :style="{ width: sidebarWidth + 'px' }">
4
4
  <div class="logo">
5
- <Logo @go-home="loadReadme" />
5
+ <div class="logo-group">
6
+ <Logo @go-home="loadFirstDoc" />
7
+ <a href="https://github.com/devsneed/md2ui" target="_blank" class="github-link" title="GitHub">
8
+ <Github :size="14" />
9
+ </a>
10
+ </div>
11
+ <button class="sidebar-toggle" @click="sidebarCollapsed = true" title="收起导航">
12
+ <PanelLeftClose :size="16" />
13
+ </button>
6
14
  </div>
7
15
  <nav class="nav-menu">
8
16
  <div class="nav-section">
@@ -30,20 +38,14 @@
30
38
  <button v-if="sidebarCollapsed" class="expand-btn expand-btn-left" @click="sidebarCollapsed = false" title="展开导航">
31
39
  <PanelLeftOpen :size="16" />
32
40
  </button>
33
- <button v-else class="collapse-btn collapse-btn-left" @click="sidebarCollapsed = true" title="收起导航">
34
- <PanelLeftClose :size="16" />
35
- </button>
36
41
  <main class="content" @scroll="handleScroll" @click="handleContentClick">
37
42
  <article class="markdown-content" v-html="htmlContent"></article>
38
43
  </main>
39
44
  <div v-if="!tocCollapsed && tocItems.length > 0" class="resizer resizer-right" @mousedown="startResize('right', $event)"></div>
40
- <TableOfContents :tocItems="tocItems" :activeHeading="activeHeading" :collapsed="tocCollapsed" :width="tocWidth" @scroll-to="scrollToHeading" />
45
+ <TableOfContents :tocItems="tocItems" :activeHeading="activeHeading" :collapsed="tocCollapsed" :width="tocWidth" @toggle="tocCollapsed = !tocCollapsed" @scroll-to="scrollToHeading" />
41
46
  <button v-if="tocCollapsed && tocItems.length > 0" class="expand-btn expand-btn-right" @click="tocCollapsed = false" title="展开目录">
42
47
  <PanelRightOpen :size="16" />
43
48
  </button>
44
- <button v-else-if="tocItems.length > 0" class="collapse-btn collapse-btn-right" @click="tocCollapsed = true" title="收起目录">
45
- <PanelRightClose :size="16" />
46
- </button>
47
49
  <transition name="fade">
48
50
  <button v-if="showBackToTop" class="back-to-top" @click="scrollToTop" title="返回顶部">
49
51
  <ArrowUp :size="20" />
@@ -55,8 +57,9 @@
55
57
  </template>
56
58
 
57
59
  <script setup>
58
- import { ref, onMounted } from 'vue'
59
- import { ChevronsDownUp, ChevronsUpDown, ArrowUp, PanelLeftOpen, PanelLeftClose, PanelRightOpen, PanelRightClose } from 'lucide-vue-next'
60
+ import { ref, onMounted, nextTick } from 'vue'
61
+ import { ChevronsDownUp, ChevronsUpDown, ArrowUp, PanelLeftOpen, PanelLeftClose, PanelRightOpen, Github } from 'lucide-vue-next'
62
+ import MD5 from 'crypto-js/md5'
60
63
  import Logo from './components/Logo.vue'
61
64
  import TreeNode from './components/TreeNode.vue'
62
65
  import TableOfContents from './components/TableOfContents.vue'
@@ -74,28 +77,53 @@ const zoomVisible = ref(false)
74
77
  const zoomContent = ref('')
75
78
 
76
79
  const { htmlContent, tocItems, renderMarkdown } = useMarkdown()
77
- const { scrollProgress, showBackToTop, activeHeading, handleScroll, scrollToHeading, scrollToTop } = useScroll()
80
+ const { scrollProgress, showBackToTop, activeHeading, handleScroll: _handleScroll, scrollToHeading: _scrollToHeading, scrollToTop } = useScroll()
81
+
82
+ // 包装滚动处理,同步锚点到 URL
83
+ function handleScroll(e) {
84
+ _handleScroll(e)
85
+ // 滚动时更新 URL 锚点
86
+ if (activeHeading.value) {
87
+ updateHash(activeHeading.value)
88
+ } else {
89
+ updateHash('')
90
+ }
91
+ }
92
+
93
+ // 包装目录点击,同步锚点到 URL
94
+ function scrollToHeading(id) {
95
+ _scrollToHeading(id)
96
+ updateHash(id)
97
+ }
78
98
  const { sidebarWidth, tocWidth, startResize } = useResize()
79
99
 
80
100
  async function loadDocsList() {
81
101
  docsList.value = await getDocsList()
82
102
  }
83
103
 
84
- async function loadReadme() {
85
- currentDoc.value = ''
86
- try {
87
- const response = await fetch('/README.md')
88
- if (response.ok) {
89
- const content = await response.text()
90
- await renderMarkdown(content)
91
- }
92
- } catch (error) {
93
- console.error('加载首页失败:', error)
104
+ // 加载第一个文档
105
+ function loadFirstDoc() {
106
+ const firstDoc = findFirstDoc(docsList.value)
107
+ if (firstDoc) {
108
+ loadDoc(firstDoc.key)
94
109
  }
95
110
  }
96
111
 
112
+ // 根据 key 生成 hash
113
+ function docHash(key) {
114
+ return MD5(key).toString()
115
+ }
116
+
117
+ // 更新 URL hash(文档hash + 可选锚点)
118
+ function updateHash(anchor) {
119
+ if (!currentDoc.value) return
120
+ const base = docHash(currentDoc.value)
121
+ window.location.hash = anchor ? `${base}/${anchor}` : base
122
+ }
123
+
97
124
  async function loadDoc(key) {
98
125
  currentDoc.value = key
126
+ updateHash('')
99
127
  const doc = findDoc(docsList.value, key)
100
128
  if (!doc) return
101
129
  try {
@@ -164,8 +192,95 @@ function handleContentClick(event) {
164
192
  }
165
193
  }
166
194
 
195
+ // 查找第一个文档
196
+ function findFirstDoc(items) {
197
+ for (const item of items) {
198
+ if (item.type === 'file') return item
199
+ if (item.type === 'folder' && item.children) {
200
+ const found = findFirstDoc(item.children)
201
+ if (found) return found
202
+ }
203
+ }
204
+ return null
205
+ }
206
+
207
+ // 显示无文档提示
208
+ function showEmptyMessage() {
209
+ renderMarkdown(`# 当前目录没有 Markdown 文档
210
+
211
+ 请在当前目录下添加 \`.md\` 文件,然后刷新页面。
212
+
213
+ ## 文档组织示例
214
+
215
+ \`\`\`
216
+ your-docs/
217
+ ├── 00-快速开始.md
218
+ ├── 01-功能特性.md
219
+ └── 02-进阶指南/
220
+ ├── 01-目录结构.md
221
+ └── 02-自定义配置.md
222
+ \`\`\`
223
+ `)
224
+ }
225
+
226
+ // 根据 hash 查找文档
227
+ function findDocByHash(items, hash) {
228
+ for (const item of items) {
229
+ if (item.type === 'file' && docHash(item.key) === hash) return item
230
+ if (item.type === 'folder' && item.children) {
231
+ const found = findDocByHash(item.children, hash)
232
+ if (found) return found
233
+ }
234
+ }
235
+ return null
236
+ }
237
+
238
+ // 展开文档所在的所有父级文件夹
239
+ function expandParents(items, targetKey) {
240
+ for (const item of items) {
241
+ if (item.type === 'file' && item.key === targetKey) return true
242
+ if (item.type === 'folder' && item.children) {
243
+ if (expandParents(item.children, targetKey)) {
244
+ item.expanded = true
245
+ return true
246
+ }
247
+ }
248
+ }
249
+ return false
250
+ }
251
+
167
252
  onMounted(async () => {
168
253
  await loadDocsList()
169
- await loadReadme()
254
+
255
+ const rawHash = window.location.hash.replace('#', '')
256
+ const [hash, anchor] = rawHash.includes('/')
257
+ ? [rawHash.split('/')[0], rawHash.split('/').slice(1).join('/')]
258
+ : [rawHash, '']
259
+
260
+ if (hash) {
261
+ const doc = findDocByHash(docsList.value, hash)
262
+ if (doc) {
263
+ expandParents(docsList.value, doc.key)
264
+ await loadDoc(doc.key)
265
+ // 恢复锚点位置
266
+ if (anchor) {
267
+ await nextTick()
268
+ // 等待 DOM 渲染完成后再滚动
269
+ setTimeout(() => {
270
+ _scrollToHeading(anchor)
271
+ updateHash(anchor)
272
+ }, 100)
273
+ }
274
+ return
275
+ }
276
+ }
277
+
278
+ // 没有 hash 或找不到对应文档,加载第一个
279
+ const firstDoc = findFirstDoc(docsList.value)
280
+ if (firstDoc) {
281
+ await loadDoc(firstDoc.key)
282
+ } else {
283
+ showEmptyMessage()
284
+ }
170
285
  })
171
286
  </script>
package/src/api/docs.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { config } from '../config.js'
2
+
1
3
  // 构建目录树结构(支持多层嵌套)
2
4
  function buildTree(files) {
3
5
  const root = { children: [] }
@@ -23,7 +25,7 @@ function buildTree(files) {
23
25
  order: match ? parseInt(match[1]) : 999,
24
26
  level: i,
25
27
  children: [],
26
- expanded: false
28
+ expanded: config.folderExpanded
27
29
  }
28
30
  currentLevel.children.push(folder)
29
31
  }
@@ -112,8 +112,10 @@ function handleOverlayClick(event) {
112
112
  function handleWheel(event) {
113
113
  event.preventDefault()
114
114
 
115
- const delta = event.deltaY > 0 ? -scaleStep : scaleStep
116
- const newScale = Math.max(minScale, Math.min(maxScale, scale.value + delta))
115
+ // 使用比例缩放,每次滚轮缩放 2%,体感更平滑
116
+ const zoomFactor = 0.02
117
+ const direction = event.deltaY > 0 ? -1 : 1
118
+ const newScale = Math.max(minScale, Math.min(maxScale, scale.value * (1 + direction * zoomFactor)))
117
119
 
118
120
  if (newScale !== scale.value) {
119
121
  scale.value = newScale
@@ -21,15 +21,15 @@ defineEmits(['go-home'])
21
21
  gap: 10px;
22
22
  color: #111827;
23
23
  cursor: pointer;
24
- transition: opacity 0.2s;
24
+ transition: all 0.15s;
25
25
  }
26
26
 
27
27
  .logo-container:hover {
28
- opacity: 0.7;
28
+ color: #3eaf7c;
29
29
  }
30
30
 
31
31
  .logo-container:active {
32
- opacity: 0.5;
32
+ transform: scale(0.96);
33
33
  }
34
34
 
35
35
  .logo-icon {
@@ -1,8 +1,13 @@
1
1
  <template>
2
2
  <aside class="toc-sidebar" v-if="tocItems.length > 0 && !collapsed" :style="{ width: width + 'px' }">
3
3
  <div class="toc-header">
4
- <List :size="16" />
5
- <span>目录</span>
4
+ <div class="toc-title">
5
+ <List :size="16" />
6
+ <span>目录</span>
7
+ </div>
8
+ <button class="toc-toggle" @click="$emit('toggle')" title="收起目录">
9
+ <PanelRightClose :size="16" />
10
+ </button>
6
11
  </div>
7
12
  <nav class="toc-nav">
8
13
  <a
@@ -19,7 +24,7 @@
19
24
  </template>
20
25
 
21
26
  <script setup>
22
- import { List } from 'lucide-vue-next'
27
+ import { List, PanelRightClose } from 'lucide-vue-next'
23
28
 
24
29
  defineProps({
25
30
  tocItems: {
@@ -40,5 +45,5 @@ defineProps({
40
45
  }
41
46
  })
42
47
 
43
- defineEmits(['scroll-to'])
48
+ defineEmits(['scroll-to', 'toggle'])
44
49
  </script>
@@ -7,12 +7,14 @@
7
7
  :class="{ expanded: item.expanded }"
8
8
  :style="{ paddingLeft: `${20 + item.level * 16}px` }"
9
9
  @click="$emit('toggle', item)"
10
+ @mouseenter="showTooltip($event, item.label)"
11
+ @mouseleave="hideTooltip"
10
12
  >
11
13
  <ChevronRight v-if="!item.expanded" class="nav-icon chevron-icon" :size="16" />
12
14
  <ChevronDown v-else class="nav-icon chevron-icon" :size="16" />
13
15
  <Folder v-if="!item.expanded" class="nav-icon folder-icon" :size="16" />
14
16
  <FolderOpen v-else class="nav-icon folder-icon" :size="16" />
15
- <span>{{ item.label }}</span>
17
+ <span class="nav-label">{{ item.label }}</span>
16
18
  </div>
17
19
 
18
20
  <!-- 递归渲染子节点 -->
@@ -34,9 +36,11 @@
34
36
  :class="{ active: currentDoc === item.key }"
35
37
  :style="{ paddingLeft: `${20 + item.level * 16}px` }"
36
38
  @click="$emit('select', item.key)"
39
+ @mouseenter="showTooltip($event, item.label)"
40
+ @mouseleave="hideTooltip"
37
41
  >
38
42
  <FileText class="nav-icon file-icon" :size="16" />
39
- <span>{{ item.label }}</span>
43
+ <span class="nav-label">{{ item.label }}</span>
40
44
  </div>
41
45
  </div>
42
46
  </template>
@@ -56,6 +60,55 @@ defineProps({
56
60
  })
57
61
 
58
62
  defineEmits(['toggle', 'select'])
63
+
64
+ let tooltipEl = null
65
+
66
+ function showTooltip(event, text) {
67
+ hideTooltip()
68
+
69
+ const target = event.currentTarget
70
+ const label = target.querySelector('.nav-label')
71
+
72
+ // 只有文字被截断时才显示 tooltip
73
+ if (label && label.scrollWidth <= label.clientWidth) return
74
+
75
+ tooltipEl = document.createElement('div')
76
+ tooltipEl.className = 'nav-tooltip'
77
+ tooltipEl.textContent = text
78
+ tooltipEl.style.cssText = `
79
+ position: fixed;
80
+ background: #1f2328;
81
+ color: #fff;
82
+ padding: 6px 10px;
83
+ border-radius: 6px;
84
+ font-size: 12px;
85
+ z-index: 9999;
86
+ max-width: 300px;
87
+ word-break: break-all;
88
+ user-select: text;
89
+ cursor: text;
90
+ box-shadow: 0 2px 8px rgba(0,0,0,0.2);
91
+ `
92
+
93
+ document.body.appendChild(tooltipEl)
94
+
95
+ const rect = target.getBoundingClientRect()
96
+ tooltipEl.style.left = `${rect.right + 8}px`
97
+ tooltipEl.style.top = `${rect.top}px`
98
+
99
+ // 防止超出屏幕右侧
100
+ const tooltipRect = tooltipEl.getBoundingClientRect()
101
+ if (tooltipRect.right > window.innerWidth - 10) {
102
+ tooltipEl.style.left = `${rect.left - tooltipRect.width - 8}px`
103
+ }
104
+ }
105
+
106
+ function hideTooltip() {
107
+ if (tooltipEl) {
108
+ tooltipEl.remove()
109
+ tooltipEl = null
110
+ }
111
+ }
59
112
  </script>
60
113
 
61
114
 
package/src/config.js ADDED
@@ -0,0 +1,8 @@
1
+ // 共享配置 - bin/md2ui.js 和 src/api/docs.js 共用
2
+ export const config = {
3
+ // 默认端口
4
+ defaultPort: 3000,
5
+
6
+ // 文件夹默认展开状态
7
+ folderExpanded: false
8
+ }