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.
@@ -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
+ }