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/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
+ })