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.
- 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 -250
- package/src/api/docs.js +2 -1
- package/src/components/AppSidebar.vue +102 -0
- package/src/components/DocContent.vue +39 -0
- 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
package/bin/build.js
ADDED
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* md2ui build - SSG 静态构建
|
|
5
|
+
*
|
|
6
|
+
* 流程:
|
|
7
|
+
* 1. 先执行 vite build 生成 SPA 产物
|
|
8
|
+
* 2. 扫描所有 .md 文件,预渲染为静态 HTML
|
|
9
|
+
* 3. 生成搜索索引 JSON
|
|
10
|
+
* 4. 产物可直接部署到 CDN / GitHub Pages / Vercel
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { fileURLToPath } from 'url'
|
|
14
|
+
import { dirname, resolve } from 'path'
|
|
15
|
+
import fs from 'fs'
|
|
16
|
+
import { pathToFileURL } from 'url'
|
|
17
|
+
import { marked } from 'marked'
|
|
18
|
+
import hljs from 'highlight.js'
|
|
19
|
+
import GithubSlugger from 'github-slugger'
|
|
20
|
+
|
|
21
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
22
|
+
const __dirname = dirname(__filename)
|
|
23
|
+
const pkgRoot = resolve(__dirname, '..')
|
|
24
|
+
|
|
25
|
+
// 用户执行命令的目录
|
|
26
|
+
const userDir = process.cwd()
|
|
27
|
+
|
|
28
|
+
// 默认配置
|
|
29
|
+
const defaultConfig = {
|
|
30
|
+
title: 'md2ui',
|
|
31
|
+
port: 3000,
|
|
32
|
+
folderExpanded: false,
|
|
33
|
+
github: '',
|
|
34
|
+
footer: '',
|
|
35
|
+
themeColor: '#3eaf7c',
|
|
36
|
+
outDir: 'dist'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ===== 工具函数 =====
|
|
40
|
+
|
|
41
|
+
// base62 字符集(与 useMarkdown.js 保持一致)
|
|
42
|
+
const BASE62 = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
|
43
|
+
|
|
44
|
+
function fnv1a64(str) {
|
|
45
|
+
let h1 = 0x811c9dc5 >>> 0
|
|
46
|
+
for (let i = 0; i < str.length; i++) {
|
|
47
|
+
h1 ^= str.charCodeAt(i)
|
|
48
|
+
h1 = Math.imul(h1, 0x01000193) >>> 0
|
|
49
|
+
}
|
|
50
|
+
let h2 = 0x050c5d1f >>> 0
|
|
51
|
+
for (let i = 0; i < str.length; i++) {
|
|
52
|
+
h2 ^= str.charCodeAt(i)
|
|
53
|
+
h2 = Math.imul(h2, 0x01000193) >>> 0
|
|
54
|
+
}
|
|
55
|
+
return [h1, h2]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toBase62(h1, h2) {
|
|
59
|
+
const num = (BigInt(h1) << 32n) | BigInt(h2)
|
|
60
|
+
let n = num
|
|
61
|
+
let result = ''
|
|
62
|
+
while (n > 0n && result.length < 12) {
|
|
63
|
+
result = BASE62[Number(n % 62n)] + result
|
|
64
|
+
n = n / 62n
|
|
65
|
+
}
|
|
66
|
+
return result.padStart(8, '0').slice(0, 8)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function docHash(key) {
|
|
70
|
+
const [h1, h2] = fnv1a64(key)
|
|
71
|
+
return toBase62(h1, h2)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
// 加载用户配置文件
|
|
76
|
+
async function loadUserConfig() {
|
|
77
|
+
const jsPath = resolve(userDir, 'md2ui.config.js')
|
|
78
|
+
if (fs.existsSync(jsPath)) {
|
|
79
|
+
try {
|
|
80
|
+
const mod = await import(pathToFileURL(jsPath).href)
|
|
81
|
+
return mod.default || mod
|
|
82
|
+
} catch (e) {
|
|
83
|
+
console.warn(' 配置文件加载失败:', e.message)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const jsonPath = resolve(userDir, '.md2uirc.json')
|
|
87
|
+
if (fs.existsSync(jsonPath)) {
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))
|
|
90
|
+
} catch (e) {
|
|
91
|
+
console.warn(' 配置文件加载失败:', e.message)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return {}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 扫描目录下的 md 文件(与 md2ui.js 保持一致)
|
|
98
|
+
function scanDocs(dir, basePath = '', level = 0, folderExpanded = false) {
|
|
99
|
+
const items = []
|
|
100
|
+
if (!fs.existsSync(dir)) return items
|
|
101
|
+
|
|
102
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true })
|
|
103
|
+
.filter(e => e.name !== 'node_modules' && !e.name.startsWith('.'))
|
|
104
|
+
.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'))
|
|
105
|
+
|
|
106
|
+
for (const entry of entries) {
|
|
107
|
+
const fullPath = resolve(dir, entry.name)
|
|
108
|
+
const relativePath = basePath ? `${basePath}/${entry.name}` : entry.name
|
|
109
|
+
|
|
110
|
+
if (entry.isDirectory()) {
|
|
111
|
+
const children = scanDocs(fullPath, relativePath, level + 1, folderExpanded)
|
|
112
|
+
if (children.length > 0) {
|
|
113
|
+
const match = entry.name.match(/^(\d+)-(.+)$/)
|
|
114
|
+
items.push({
|
|
115
|
+
key: relativePath,
|
|
116
|
+
label: match ? match[2] : entry.name,
|
|
117
|
+
order: match ? parseInt(match[1]) : 999,
|
|
118
|
+
type: 'folder',
|
|
119
|
+
level,
|
|
120
|
+
expanded: folderExpanded,
|
|
121
|
+
children
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
} else if (entry.name.endsWith('.md')) {
|
|
125
|
+
const match = entry.name.match(/^(\d+)-(.+)\.md$/)
|
|
126
|
+
const label = match ? match[2] : entry.name.replace(/\.md$/, '')
|
|
127
|
+
items.push({
|
|
128
|
+
key: relativePath.replace(/\.md$/, ''),
|
|
129
|
+
label,
|
|
130
|
+
order: match ? parseInt(match[1]) : 999,
|
|
131
|
+
type: 'file',
|
|
132
|
+
level,
|
|
133
|
+
path: resolve(dir, entry.name),
|
|
134
|
+
relativePath
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
items.sort((a, b) => a.order - b.order)
|
|
140
|
+
return items
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 扁平化文档树
|
|
144
|
+
function flattenDocs(items, result = []) {
|
|
145
|
+
for (const item of items) {
|
|
146
|
+
if (item.type === 'file') result.push(item)
|
|
147
|
+
if (item.type === 'folder' && item.children) flattenDocs(item.children, result)
|
|
148
|
+
}
|
|
149
|
+
return result
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 解析 YAML frontmatter
|
|
153
|
+
function parseFrontmatter(markdown) {
|
|
154
|
+
const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/)
|
|
155
|
+
if (!match) return { data: {}, content: markdown }
|
|
156
|
+
const yamlStr = match[1]
|
|
157
|
+
const data = {}
|
|
158
|
+
for (const line of yamlStr.split('\n')) {
|
|
159
|
+
const m = line.match(/^(\w+)\s*:\s*(.+)$/)
|
|
160
|
+
if (!m) continue
|
|
161
|
+
let val = m[2].trim()
|
|
162
|
+
if (val === 'true') val = true
|
|
163
|
+
else if (val === 'false') val = false
|
|
164
|
+
else if (/^\d+$/.test(val)) val = parseInt(val)
|
|
165
|
+
else val = val.replace(/^['"]|['"]$/g, '')
|
|
166
|
+
data[m[1]] = val
|
|
167
|
+
}
|
|
168
|
+
return { data, content: markdown.slice(match[0].length) }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 计算阅读时间
|
|
172
|
+
function calcReadingTime(markdown) {
|
|
173
|
+
const clean = markdown
|
|
174
|
+
.replace(/^---[\s\S]*?---\n?/, '')
|
|
175
|
+
.replace(/```[\s\S]*?```/g, '')
|
|
176
|
+
.replace(/<[^>]+>/g, '')
|
|
177
|
+
.replace(/[#*_~`>\-|[\]()!]/g, '')
|
|
178
|
+
const cnChars = (clean.match(/[\u4e00-\u9fff]/g) || []).length
|
|
179
|
+
const enWords = clean.replace(/[\u4e00-\u9fff]/g, '').split(/\s+/).filter(w => w.length > 0).length
|
|
180
|
+
const totalChars = cnChars + enWords
|
|
181
|
+
const minutes = Math.ceil(cnChars / 400 + enWords / 200)
|
|
182
|
+
return { totalChars, minutes: Math.max(1, minutes) }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ===== Markdown 渲染(SSG 版本,使用 marked + hljs,不含 Mermaid 客户端渲染) =====
|
|
186
|
+
|
|
187
|
+
async function renderMarkdownToHtml(markdown, currentDocKey, docsList) {
|
|
188
|
+
const { data: frontmatter, content } = parseFrontmatter(markdown)
|
|
189
|
+
const slugger = new GithubSlugger()
|
|
190
|
+
const renderer = new marked.Renderer()
|
|
191
|
+
|
|
192
|
+
// 标题渲染
|
|
193
|
+
renderer.heading = function(text, level) {
|
|
194
|
+
const id = slugger.slug(text)
|
|
195
|
+
return `<h${level} id="${id}">${text}</h${level}>\n`
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 代码块渲染
|
|
199
|
+
renderer.code = function(code, language) {
|
|
200
|
+
if (language === 'mermaid') {
|
|
201
|
+
const id = 'mermaid-' + Math.random().toString(36).substring(2, 11)
|
|
202
|
+
return `<div class="mermaid" id="${id}">${code}</div>`
|
|
203
|
+
}
|
|
204
|
+
let highlighted
|
|
205
|
+
if (language && hljs.getLanguage(language)) {
|
|
206
|
+
highlighted = hljs.highlight(code, { language }).value
|
|
207
|
+
} else {
|
|
208
|
+
highlighted = hljs.highlightAuto(code).value
|
|
209
|
+
}
|
|
210
|
+
const langLabel = language || ''
|
|
211
|
+
return `<div class="code-block-wrapper">
|
|
212
|
+
<div class="code-block-header">
|
|
213
|
+
<span class="code-lang-label">${langLabel}</span>
|
|
214
|
+
<button class="copy-code-btn" title="复制代码">
|
|
215
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>
|
|
216
|
+
<span class="copy-text">复制</span>
|
|
217
|
+
</button>
|
|
218
|
+
</div>
|
|
219
|
+
<pre><code class="hljs${language ? ` language-${language}` : ''}">${highlighted}</code></pre>
|
|
220
|
+
</div>`
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 链接渲染(SSG 版本:站内链接改写为相对路径)
|
|
224
|
+
renderer.link = function(href, title, text) {
|
|
225
|
+
const decoded = decodeURIComponent(href || '')
|
|
226
|
+
const titleAttr = title ? ` title="${title}"` : ''
|
|
227
|
+
|
|
228
|
+
// 站外链接
|
|
229
|
+
if (/^(https?|mailto|tel):/.test(decoded)) {
|
|
230
|
+
return `<a href="${href}"${titleAttr} target="_blank" rel="noopener">${text}</a>`
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 纯锚点
|
|
234
|
+
if (decoded.startsWith('#')) {
|
|
235
|
+
const anchor = slugger.slug(decoded.slice(1), false)
|
|
236
|
+
return `<a href="#${anchor}"${titleAttr}>${text}</a>`
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// .md 文档链接
|
|
240
|
+
if (decoded.endsWith('.md') || decoded.includes('.md#')) {
|
|
241
|
+
const [mdPath, anchor] = decoded.includes('#') ? decoded.split('#') : [decoded, '']
|
|
242
|
+
const targetKey = resolveDocKey(mdPath, currentDocKey)
|
|
243
|
+
const doc = findDocInTree(docsList, targetKey)
|
|
244
|
+
if (doc) {
|
|
245
|
+
const hash = docHash(doc.key)
|
|
246
|
+
const anchorSlug = anchor ? slugger.slug(anchor, false) : ''
|
|
247
|
+
const url = anchorSlug ? `/${hash}.html#${anchorSlug}` : `/${hash}.html`
|
|
248
|
+
return `<a href="${url}" data-doc-key="${doc.key}"${anchorSlug ? ` data-anchor="${anchorSlug}"` : ''}${titleAttr}>${text}</a>`
|
|
249
|
+
}
|
|
250
|
+
return `<a href="javascript:void(0)" class="broken-link" title="文档未找到: ${decoded}">${text}</a>`
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return `<a href="${href}"${titleAttr} target="_blank" rel="noopener">${text}</a>`
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
marked.setOptions({ renderer, breaks: true, gfm: true, headerIds: false, mangle: false })
|
|
257
|
+
let html = marked.parse(content)
|
|
258
|
+
|
|
259
|
+
// frontmatter.title 覆盖 h1
|
|
260
|
+
if (frontmatter.title) {
|
|
261
|
+
const titleSlugger = new GithubSlugger()
|
|
262
|
+
html = html.replace(/<h1[^>]*>[\s\S]*?<\/h1>/, `<h1 id="${titleSlugger.slug(frontmatter.title)}">${frontmatter.title}</h1>`)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// 阅读元信息
|
|
266
|
+
const { totalChars, minutes } = calcReadingTime(content)
|
|
267
|
+
if (totalChars > 0) {
|
|
268
|
+
const metaParts = [`${totalChars} 字`, `约 ${minutes} 分钟`]
|
|
269
|
+
if (frontmatter.description) metaParts.push(frontmatter.description)
|
|
270
|
+
const metaHtml = `<div class="doc-meta">${metaParts.map(p => `<span class="doc-meta-item">${p}</span>`).join('<span class="doc-meta-sep">·</span>')}</div>`
|
|
271
|
+
html = html.replace(/(<\/h1>)/, `$1\n${metaHtml}`)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { html, frontmatter, title: frontmatter.title || '' }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// 解析相对路径
|
|
278
|
+
function resolveDocKey(href, currentDocKey) {
|
|
279
|
+
const currentParts = currentDocKey.split('/')
|
|
280
|
+
currentParts.pop()
|
|
281
|
+
const linkParts = href.replace(/\.md$/, '').split('/')
|
|
282
|
+
const resolved = [...currentParts]
|
|
283
|
+
for (const part of linkParts) {
|
|
284
|
+
if (part === '.' || part === '') continue
|
|
285
|
+
if (part === '..') { resolved.pop(); continue }
|
|
286
|
+
resolved.push(part)
|
|
287
|
+
}
|
|
288
|
+
return resolved.join('/')
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// 在文档树中查找文档
|
|
292
|
+
function findDocInTree(items, key) {
|
|
293
|
+
for (const item of items) {
|
|
294
|
+
if (item.type === 'file' && item.key === key) return item
|
|
295
|
+
if (item.type === 'folder' && item.children) {
|
|
296
|
+
const found = findDocInTree(item.children, key)
|
|
297
|
+
if (found) return found
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return null
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// 生成搜索索引 JSON
|
|
304
|
+
function buildSearchIndex(docs) {
|
|
305
|
+
return docs.map(doc => {
|
|
306
|
+
const content = fs.readFileSync(doc.path, 'utf-8')
|
|
307
|
+
const { content: cleanContent } = parseFrontmatter(content)
|
|
308
|
+
return {
|
|
309
|
+
id: doc.key,
|
|
310
|
+
title: doc.label,
|
|
311
|
+
content: cleanContent.replace(/```[\s\S]*?```/g, '').replace(/<[^>]+>/g, '').substring(0, 5000),
|
|
312
|
+
hash: docHash(doc.key)
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// 生成侧边栏 HTML(递归)
|
|
318
|
+
function renderSidebarHtml(items, currentKey) {
|
|
319
|
+
let html = ''
|
|
320
|
+
for (const item of items) {
|
|
321
|
+
if (item.type === 'folder') {
|
|
322
|
+
const isActive = containsDoc(item, currentKey)
|
|
323
|
+
html += `<div class="nav-folder-group${isActive ? ' open' : ''}">`
|
|
324
|
+
html += `<div class="nav-item nav-folder level-${item.level}">`
|
|
325
|
+
html += `<span class="nav-icon chevron-icon"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></span>`
|
|
326
|
+
html += `<span class="nav-icon folder-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span>`
|
|
327
|
+
html += `<span class="nav-label">${item.label}</span></div>`
|
|
328
|
+
html += `<div class="nav-children">${renderSidebarHtml(item.children, currentKey)}</div></div>`
|
|
329
|
+
} else {
|
|
330
|
+
const hash = docHash(item.key)
|
|
331
|
+
const isActive = item.key === currentKey
|
|
332
|
+
html += `<a href="/${hash}.html" class="nav-item level-${item.level}${isActive ? ' active' : ''}">`
|
|
333
|
+
html += `<span class="nav-icon file-icon"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg></span>`
|
|
334
|
+
html += `<span class="nav-label">${item.label}</span></a>`
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return html
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// 检查文件夹是否包含指定文档
|
|
341
|
+
function containsDoc(folder, key) {
|
|
342
|
+
if (!folder.children) return false
|
|
343
|
+
for (const item of folder.children) {
|
|
344
|
+
if (item.type === 'file' && item.key === key) return true
|
|
345
|
+
if (item.type === 'folder' && containsDoc(item, key)) return true
|
|
346
|
+
}
|
|
347
|
+
return false
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// 生成上一篇/下一篇导航 HTML
|
|
351
|
+
function renderDocNav(flatDocs, currentIdx) {
|
|
352
|
+
const prev = currentIdx > 0 ? flatDocs[currentIdx - 1] : null
|
|
353
|
+
const next = currentIdx < flatDocs.length - 1 ? flatDocs[currentIdx + 1] : null
|
|
354
|
+
if (!prev && !next) return ''
|
|
355
|
+
let html = '<nav class="doc-nav">'
|
|
356
|
+
if (prev) {
|
|
357
|
+
html += `<a href="/${docHash(prev.key)}.html" class="doc-nav-link prev">`
|
|
358
|
+
html += `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>`
|
|
359
|
+
html += `<div class="doc-nav-text"><span class="doc-nav-label">上一篇</span><span class="doc-nav-title">${prev.label}</span></div></a>`
|
|
360
|
+
} else {
|
|
361
|
+
html += '<div></div>'
|
|
362
|
+
}
|
|
363
|
+
if (next) {
|
|
364
|
+
html += `<a href="/${docHash(next.key)}.html" class="doc-nav-link next">`
|
|
365
|
+
html += `<div class="doc-nav-text"><span class="doc-nav-label">下一篇</span><span class="doc-nav-title">${next.label}</span></div>`
|
|
366
|
+
html += `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></a>`
|
|
367
|
+
}
|
|
368
|
+
html += '</nav>'
|
|
369
|
+
return html
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// 生成完整的静态 HTML 页面
|
|
373
|
+
function generatePageHtml(options) {
|
|
374
|
+
const { title, siteTitle, contentHtml, sidebarHtml, docNavHtml, cssContent, themeColor, isWelcome } = options
|
|
375
|
+
const pageTitle = isWelcome ? siteTitle : `${title} - ${siteTitle}`
|
|
376
|
+
|
|
377
|
+
return `<!DOCTYPE html>
|
|
378
|
+
<html lang="zh-CN">
|
|
379
|
+
<head>
|
|
380
|
+
<meta charset="UTF-8">
|
|
381
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
382
|
+
<title>${pageTitle}</title>
|
|
383
|
+
<meta name="description" content="${title}">
|
|
384
|
+
<meta name="theme-color" content="${themeColor}">
|
|
385
|
+
<link rel="icon" type="image/svg+xml" href="/logo.svg">
|
|
386
|
+
<style>${cssContent}</style>
|
|
387
|
+
</head>
|
|
388
|
+
<body>
|
|
389
|
+
<div class="container ssg-page">
|
|
390
|
+
<aside class="sidebar" id="sidebar">
|
|
391
|
+
<div class="logo">
|
|
392
|
+
<div class="logo-group">
|
|
393
|
+
<a href="/index.html" class="logo-link">${siteTitle}</a>
|
|
394
|
+
</div>
|
|
395
|
+
</div>
|
|
396
|
+
</div>
|
|
397
|
+
<nav class="nav-menu">
|
|
398
|
+
<div class="nav-section"><span>文档目录</span></div>
|
|
399
|
+
${sidebarHtml}
|
|
400
|
+
</nav>
|
|
401
|
+
</aside>
|
|
402
|
+
<main class="content">
|
|
403
|
+
${isWelcome ? generateWelcomeHtml(siteTitle) : `<article class="markdown-content">${contentHtml}</article>${docNavHtml}`}
|
|
404
|
+
</main>
|
|
405
|
+
</div>
|
|
406
|
+
<script>${getInlineScript()}</script>
|
|
407
|
+
</body>
|
|
408
|
+
</html>`
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// 欢迎页 HTML
|
|
412
|
+
function generateWelcomeHtml(siteTitle) {
|
|
413
|
+
return `<div class="welcome-page">
|
|
414
|
+
<div class="welcome-hero">
|
|
415
|
+
<svg width="64" height="64" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
416
|
+
<rect x="6" y="4" width="20" height="24" rx="2" stroke="currentColor" stroke-width="2" fill="none"/>
|
|
417
|
+
<line x1="10" y1="10" x2="22" y2="10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
|
418
|
+
<line x1="10" y1="16" x2="22" y2="16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
|
419
|
+
<line x1="10" y1="22" x2="17" y2="22" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
|
420
|
+
</svg>
|
|
421
|
+
<h1 class="welcome-title">${siteTitle}</h1>
|
|
422
|
+
<p class="welcome-desc">将 Markdown 文档转换为美观易读的网页</p>
|
|
423
|
+
</div>
|
|
424
|
+
</div>`
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// 内联 JS:主题切换 + 侧边栏折叠 + 代码复制 + Mermaid 延迟渲染
|
|
428
|
+
function getInlineScript() {
|
|
429
|
+
return `
|
|
430
|
+
// 侧边栏折叠
|
|
431
|
+
document.querySelectorAll('.nav-folder-group').forEach(function(g){
|
|
432
|
+
g.querySelector('.nav-folder').addEventListener('click',function(){g.classList.toggle('open')})});
|
|
433
|
+
|
|
434
|
+
// 代码复制
|
|
435
|
+
document.querySelectorAll('.copy-code-btn').forEach(function(btn){
|
|
436
|
+
btn.addEventListener('click',function(){
|
|
437
|
+
var code=btn.closest('.code-block-wrapper').querySelector('code');
|
|
438
|
+
if(!code)return;
|
|
439
|
+
navigator.clipboard.writeText(code.textContent).then(function(){
|
|
440
|
+
var t=btn.querySelector('.copy-text');t.textContent='已复制';
|
|
441
|
+
btn.classList.add('copied');
|
|
442
|
+
setTimeout(function(){t.textContent='复制';btn.classList.remove('copied')},2000)
|
|
443
|
+
})
|
|
444
|
+
})
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
// 移动端菜单
|
|
448
|
+
var sidebar=document.getElementById('sidebar'),overlay=document.getElementById('drawer-overlay');
|
|
449
|
+
function openDrawer(){if(sidebar){sidebar.classList.add('drawer-open');if(overlay)overlay.style.display='block'}}
|
|
450
|
+
function closeDrawer(){if(sidebar){sidebar.classList.remove('drawer-open');if(overlay)overlay.style.display='none'}}
|
|
451
|
+
|
|
452
|
+
// Mermaid 延迟渲染
|
|
453
|
+
(function(){var els=document.querySelectorAll('.mermaid');
|
|
454
|
+
if(els.length===0)return;
|
|
455
|
+
var s=document.createElement('script');s.src='https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js';
|
|
456
|
+
s.onload=function(){mermaid.initialize({startOnLoad:false,theme:'base',themeVariables:{primaryColor:'#e8eaf6',primaryTextColor:'#37474f',primaryBorderColor:'#7986cb',lineColor:'#90a4ae',textColor:'#455a64',secondaryColor:'#f3e5f5',secondaryBorderColor:'#ba68c8',tertiaryColor:'#e0f7fa',tertiaryBorderColor:'#4dd0e1',fontFamily:'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',fontSize:'14px',actorBkg:'#e8eaf6',actorBorder:'#7986cb',signalColor:'#5c6bc0',sectionBkgColor:'#e8eaf6',altSectionBkgColor:'#f3e5f5',taskBkgColor:'#7986cb',taskTextColor:'#ffffff',activeTaskBkgColor:'#5c6bc0',doneTaskBkgColor:'#9fa8da',pie1:'#7986cb',pie2:'#ba68c8',pie3:'#4dd0e1',pie4:'#ffb74d',pie5:'#a1887f',mainBkg:'#e8eaf6',background:'#ffffff'}});
|
|
457
|
+
els.forEach(function(el){var id=el.id;var code=el.textContent;
|
|
458
|
+
mermaid.render(id+'-svg',code).then(function(r){el.innerHTML=r.svg;el.classList.add('zoomable-image');el.style.cursor='zoom-in'})
|
|
459
|
+
.catch(function(e){el.innerHTML='<pre class=\"mermaid-error\">图表渲染失败\\n'+e.message+'</pre>'})})};
|
|
460
|
+
document.head.appendChild(s)})();
|
|
461
|
+
|
|
462
|
+
`
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// 生成 SSG 专用 CSS(从 style.css 读取 + 补充 SSG 特有样式)
|
|
466
|
+
function getSsgCss(pkgRoot) {
|
|
467
|
+
let css = fs.readFileSync(resolve(pkgRoot, 'src/style.css'), 'utf-8')
|
|
468
|
+
// 追加 SSG 特有样式
|
|
469
|
+
css += `
|
|
470
|
+
/* SSG 特有样式 */
|
|
471
|
+
.ssg-page .sidebar { width: 280px; }
|
|
472
|
+
.ssg-page .logo-link {
|
|
473
|
+
font-size: 16px; font-weight: 700; color: var(--color-text);
|
|
474
|
+
text-decoration: none; letter-spacing: -0.02em;
|
|
475
|
+
}
|
|
476
|
+
.ssg-page .logo-link:hover { color: var(--color-accent); }
|
|
477
|
+
.nav-folder-group .nav-children { display: none; }
|
|
478
|
+
.nav-folder-group.open .nav-children { display: block; }
|
|
479
|
+
.nav-folder-group.open > .nav-folder .chevron-icon svg { transform: rotate(90deg); }
|
|
480
|
+
.nav-folder-group .chevron-icon svg { transition: transform 0.15s; }
|
|
481
|
+
.ssg-page .nav-item { text-decoration: none; }
|
|
482
|
+
.ssg-page .welcome-page {
|
|
483
|
+
display: flex; flex-direction: column; align-items: center;
|
|
484
|
+
justify-content: center; min-height: 100%; padding: 64px 32px;
|
|
485
|
+
}
|
|
486
|
+
.ssg-page .welcome-hero {
|
|
487
|
+
display: flex; flex-direction: column; align-items: center; gap: 16px;
|
|
488
|
+
color: var(--color-accent);
|
|
489
|
+
}
|
|
490
|
+
.ssg-page .welcome-title {
|
|
491
|
+
font-size: 40px; font-weight: 700; color: var(--color-text);
|
|
492
|
+
border: none; padding: 0; margin: 0;
|
|
493
|
+
}
|
|
494
|
+
.ssg-page .welcome-desc { font-size: 16px; color: var(--color-text-secondary); margin: 0; }
|
|
495
|
+
/* 移动端响应式 */
|
|
496
|
+
@media (max-width: 768px) {
|
|
497
|
+
.ssg-page .sidebar {
|
|
498
|
+
position: fixed; top: 0; left: 0; bottom: 0; width: 280px !important;
|
|
499
|
+
z-index: 600; transform: translateX(-100%); transition: transform 0.25s ease;
|
|
500
|
+
}
|
|
501
|
+
.ssg-page .sidebar.drawer-open { transform: translateX(0); }
|
|
502
|
+
}
|
|
503
|
+
`
|
|
504
|
+
return css
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
// ===== 主构建流程 =====
|
|
510
|
+
async function build() {
|
|
511
|
+
console.log('\n md2ui build - 静态站点生成\n')
|
|
512
|
+
console.log(` 扫描目录: ${userDir}\n`)
|
|
513
|
+
|
|
514
|
+
// 加载配置
|
|
515
|
+
const userConfig = await loadUserConfig()
|
|
516
|
+
const siteConfig = { ...defaultConfig, ...userConfig }
|
|
517
|
+
const outDir = resolve(userDir, siteConfig.outDir || 'dist')
|
|
518
|
+
|
|
519
|
+
// 扫描文档
|
|
520
|
+
const docsList = scanDocs(userDir, '', 0, siteConfig.folderExpanded)
|
|
521
|
+
const flatDocs = flattenDocs(docsList)
|
|
522
|
+
|
|
523
|
+
if (flatDocs.length === 0) {
|
|
524
|
+
console.log(' 当前目录下没有找到 Markdown 文件\n')
|
|
525
|
+
process.exit(1)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
console.log(` 找到 ${flatDocs.length} 个文档\n`)
|
|
529
|
+
|
|
530
|
+
// 清理输出目录
|
|
531
|
+
if (fs.existsSync(outDir)) {
|
|
532
|
+
fs.rmSync(outDir, { recursive: true })
|
|
533
|
+
}
|
|
534
|
+
fs.mkdirSync(outDir, { recursive: true })
|
|
535
|
+
|
|
536
|
+
// 读取 CSS
|
|
537
|
+
const cssContent = getSsgCss(pkgRoot)
|
|
538
|
+
|
|
539
|
+
// 复制 logo
|
|
540
|
+
const logoSrc = resolve(pkgRoot, 'public/logo.svg')
|
|
541
|
+
if (fs.existsSync(logoSrc)) {
|
|
542
|
+
fs.copyFileSync(logoSrc, resolve(outDir, 'logo.svg'))
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// 生成每个文档的静态 HTML
|
|
546
|
+
let count = 0
|
|
547
|
+
for (let i = 0; i < flatDocs.length; i++) {
|
|
548
|
+
const doc = flatDocs[i]
|
|
549
|
+
const markdown = fs.readFileSync(doc.path, 'utf-8')
|
|
550
|
+
const { html: contentHtml, title } = await renderMarkdownToHtml(markdown, doc.key, docsList)
|
|
551
|
+
const sidebarHtml = renderSidebarHtml(docsList, doc.key)
|
|
552
|
+
const docNavHtml = renderDocNav(flatDocs, i)
|
|
553
|
+
const hash = docHash(doc.key)
|
|
554
|
+
|
|
555
|
+
const pageHtml = generatePageHtml({
|
|
556
|
+
title: title || doc.label,
|
|
557
|
+
siteTitle: siteConfig.title,
|
|
558
|
+
contentHtml,
|
|
559
|
+
sidebarHtml,
|
|
560
|
+
docNavHtml,
|
|
561
|
+
cssContent,
|
|
562
|
+
themeColor: siteConfig.themeColor,
|
|
563
|
+
isWelcome: false
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
fs.writeFileSync(resolve(outDir, `${hash}.html`), pageHtml, 'utf-8')
|
|
567
|
+
count++
|
|
568
|
+
process.stdout.write(`\r 生成页面: ${count}/${flatDocs.length}`)
|
|
569
|
+
}
|
|
570
|
+
console.log('')
|
|
571
|
+
|
|
572
|
+
// 生成首页(欢迎页)
|
|
573
|
+
const indexHtml = generatePageHtml({
|
|
574
|
+
title: siteConfig.title,
|
|
575
|
+
siteTitle: siteConfig.title,
|
|
576
|
+
contentHtml: '',
|
|
577
|
+
sidebarHtml: renderSidebarHtml(docsList, ''),
|
|
578
|
+
docNavHtml: '',
|
|
579
|
+
cssContent,
|
|
580
|
+
themeColor: siteConfig.themeColor,
|
|
581
|
+
isWelcome: true
|
|
582
|
+
})
|
|
583
|
+
fs.writeFileSync(resolve(outDir, 'index.html'), indexHtml, 'utf-8')
|
|
584
|
+
|
|
585
|
+
// 生成搜索索引
|
|
586
|
+
const searchData = buildSearchIndex(flatDocs)
|
|
587
|
+
fs.writeFileSync(resolve(outDir, 'search-index.json'), JSON.stringify(searchData), 'utf-8')
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
// 生成 404.html(GitHub Pages SPA fallback)
|
|
592
|
+
fs.copyFileSync(resolve(outDir, 'index.html'), resolve(outDir, '404.html'))
|
|
593
|
+
|
|
594
|
+
// 生成 .nojekyll(GitHub Pages 不处理下划线文件)
|
|
595
|
+
fs.writeFileSync(resolve(outDir, '.nojekyll'), '', 'utf-8')
|
|
596
|
+
|
|
597
|
+
console.log(`\n 构建完成:`)
|
|
598
|
+
console.log(` 输出目录: ${outDir}`)
|
|
599
|
+
console.log(` 页面数量: ${count + 1} (含首页)`)
|
|
600
|
+
console.log(` 搜索索引: search-index.json`)
|
|
601
|
+
|
|
602
|
+
console.log(`\n 可直接部署到 GitHub Pages / Vercel / Netlify / CDN\n`)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
build().catch(err => {
|
|
606
|
+
console.error('\n 构建失败:', err.message)
|
|
607
|
+
console.error(err.stack)
|
|
608
|
+
process.exit(1)
|
|
609
|
+
})
|