From 4432306346e8e6938766cda5ef0f0ec04d3a6bc5 Mon Sep 17 00:00:00 2001 From: xiaolfeng Date: Tue, 22 Sep 2026 21:05:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E9=A2=84=E8=A7=88):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=20MDX=20=E7=8B=AC=E7=AB=8B=E6=96=87=E6=A1=A3=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E4=B8=8E=E4=BA=A4=E4=BA=92=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原因: 预览工作台此前缺乏对 .mdx 文件的独立渲染能力,直接回退至纯代码视图。 内容: - 后端新增 PreviewMimeMDX 常量与 MIME 推断,支持 MDX 入口文件自动解析 - 共享组件库新增 mdx 模块,支持 YAML frontmatter 解析、头部卡片与目录大纲 - 控制台前端扩展 PreviewKind,完成文件查看器分发、Pages 展示与 Q&A 内联适配 --- components/package.json | 1 + components/src/index.ts | 2 + components/src/mdx/frontmatter.test.ts | 80 ++++++++ components/src/mdx/frontmatter.ts | 124 ++++++++++++ components/src/mdx/index.ts | 8 + components/src/mdx/mdx-header.tsx | 175 +++++++++++++++++ components/src/mdx/mdx-viewer.test.tsx | 70 +++++++ components/src/mdx/mdx-viewer.tsx | 181 ++++++++++++++++++ internal/constant/preview.go | 1 + internal/handler/preview.go | 25 +-- internal/handler/serve.go | 2 +- internal/logic/preview_entry.go | 158 ++++++++++++++- internal/logic/preview_entry_test.go | 40 ++++ internal/logic/preview_logic.go | 2 + internal/logic/preview_mime_test.go | 1 + internal/mcp/preview_handlers.go | 4 +- .../interact/primitives/preview-frame.tsx | 4 + .../components/pages/showcase-shell.test.ts | 1 + web/src/components/pages/showcase-shell.tsx | 2 +- .../components/preview/file-viewer.test.tsx | 40 ++++ web/src/components/preview/file-viewer.tsx | 5 + web/src/lib/format-preview-source.test.ts | 2 + web/src/lib/preview-file.ts | 5 +- 23 files changed, 903 insertions(+), 30 deletions(-) create mode 100644 components/src/mdx/frontmatter.test.ts create mode 100644 components/src/mdx/frontmatter.ts create mode 100644 components/src/mdx/index.ts create mode 100644 components/src/mdx/mdx-header.tsx create mode 100644 components/src/mdx/mdx-viewer.test.tsx create mode 100644 components/src/mdx/mdx-viewer.tsx create mode 100644 web/src/components/preview/file-viewer.test.tsx diff --git a/components/package.json b/components/package.json index 2b05112..b0c3990 100644 --- a/components/package.json +++ b/components/package.json @@ -12,6 +12,7 @@ ".": "./src/index.ts", "./ui/*": "./src/ui/*", "./markdown": "./src/markdown/index.ts", + "./mdx": "./src/mdx/index.ts", "./lpw": "./src/lpw/index.ts", "./motion": "./src/motion/index.ts", "./utils": "./src/lib/utils.ts", diff --git a/components/src/index.ts b/components/src/index.ts index b1d57e6..95cde9e 100644 --- a/components/src/index.ts +++ b/components/src/index.ts @@ -1 +1,3 @@ // @lumina/components - shared UI package entry point +export * from './mdx' + diff --git a/components/src/mdx/frontmatter.test.ts b/components/src/mdx/frontmatter.test.ts new file mode 100644 index 0000000..99fa8c0 --- /dev/null +++ b/components/src/mdx/frontmatter.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { parseFrontmatter } from './frontmatter' + +describe('parseFrontmatter', () => { + it('正确解析包含 title, description, icon 的标准 frontmatter', () => { + const raw = `--- +title: "微明系统架构" +description: "深入解析知识中枢的核心模块与状态流转" +icon: ShieldCheck +tags: [auth, security] +--- + +# 正文标题 +正文内容段落。` + + const result = parseFrontmatter(raw) + expect(result.frontmatter).toEqual({ + title: '微明系统架构', + description: '深入解析知识中枢的核心模块与状态流转', + icon: 'ShieldCheck', + tags: ['auth', 'security'], + }) + expect(result.body.trim()).toBe('# 正文标题\n正文内容段落。') + }) + + it('支持多行列表语法的 tags', () => { + const raw = `--- +title: 标签列表测试 +tags: + - react + - typescript + - lumina +--- + +正文` + + const result = parseFrontmatter(raw) + expect(result.frontmatter?.tags).toEqual(['react', 'typescript', 'lumina']) + expect(result.body.trim()).toBe('正文') + }) + + it('无 frontmatter 时原样返回 body 且 frontmatter 为 null', () => { + const raw = '# 仅有正文\n\n没有元数据。' + const result = parseFrontmatter(raw) + expect(result.frontmatter).toBeNull() + expect(result.body).toBe(raw) + }) + + it('未闭合的分隔符时原样返回 body 且 frontmatter 为 null', () => { + const raw = `--- +title: 未闭合的块 +正文` + const result = parseFrontmatter(raw) + expect(result.frontmatter).toBeNull() + expect(result.body).toBe(raw) + }) + + it('忽略注释行与空行', () => { + const raw = `--- +# 这是注释 +title: 注释测试 + +author: XiaoLfeng +--- + +正文` + const result = parseFrontmatter(raw) + expect(result.frontmatter?.title).toBe('注释测试') + expect(result.frontmatter?.author).toBe('XiaoLfeng') + }) + + it('支持结尾直接是文件末尾的分隔符', () => { + const raw = `--- +title: 仅有头部 +---` + const result = parseFrontmatter(raw) + expect(result.frontmatter?.title).toBe('仅有头部') + expect(result.body).toBe('') + }) +}) diff --git a/components/src/mdx/frontmatter.ts b/components/src/mdx/frontmatter.ts new file mode 100644 index 0000000..13e210d --- /dev/null +++ b/components/src/mdx/frontmatter.ts @@ -0,0 +1,124 @@ +/** + * frontmatter.ts + * + * 轻量且稳健的 YAML frontmatter 纯前端解析器。 + * 用于从 .mdx 文档中提取结构化元数据(title, description, icon, tags 等)并分离正文。 + */ + +export interface MdxFrontmatter { + title?: string + description?: string + icon?: string + tags?: string[] + date?: string + last_updated?: string + [key: string]: unknown +} + +export function parseFrontmatter(raw: string): { + frontmatter: MdxFrontmatter | null + body: string +} { + if (!raw || typeof raw !== 'string') { + return { frontmatter: null, body: '' } + } + + // 必须以 ---\n 或 ---\r\n 开头 + if (!raw.startsWith('---\n') && !raw.startsWith('---\r\n')) { + return { frontmatter: null, body: raw } + } + + const delimiterLen = raw.startsWith('---\r\n') ? 5 : 4 + const searchSlice = raw.slice(delimiterLen) + + // 寻找闭合的分隔符(\n---\n 或 \n---\r\n 或末尾 \n---) + const closeOffset = searchSlice.search(/\r?\n---(?:\r?\n|$)/) + if (closeOffset === -1) { + return { frontmatter: null, body: raw } + } + + const fmBlock = searchSlice.slice(0, closeOffset) + const afterMatch = searchSlice.slice(closeOffset).match(/^\r?\n---(?:\r?\n)?/) + const matchLen = afterMatch ? afterMatch[0].length : 4 + const body = searchSlice.slice(closeOffset + matchLen) + + const frontmatter: MdxFrontmatter = {} + const lines = fmBlock.split(/\r?\n/) + let currentListKey: string | null = null + + for (const line of lines) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) { + continue + } + + // 处理列表项(例如 tags 下的 - item) + if (trimmed.startsWith('- ') && currentListKey) { + const itemVal = cleanQuotes(trimmed.slice(2).trim()) + const list = frontmatter[currentListKey] + if (Array.isArray(list)) { + list.push(itemVal) + } else { + frontmatter[currentListKey] = [itemVal] + } + continue + } + + // 处理普通 key: value + const colonIdx = trimmed.indexOf(':') + if (colonIdx === -1) { + currentListKey = null + continue + } + + const key = trimmed.slice(0, colonIdx).trim() + const rawVal = trimmed.slice(colonIdx + 1).trim() + + if (!key) continue + + if (!rawVal) { + // 可能是接下来的列表开头(如 tags:) + currentListKey = key + frontmatter[key] = [] + continue + } + + currentListKey = null + + // 解析内联数组,例如 [tag1, tag2] + if (rawVal.startsWith('[') && rawVal.endsWith(']')) { + const inner = rawVal.slice(1, -1).trim() + const items = inner + ? inner.split(',').map((it) => cleanQuotes(it.trim())).filter(Boolean) + : [] + frontmatter[key] = items + continue + } + + // 布尔值与数值处理 + if (rawVal === 'true') { + frontmatter[key] = true + } else if (rawVal === 'false') { + frontmatter[key] = false + } else { + frontmatter[key] = cleanQuotes(rawVal) + } + } + + // 特殊规整:如果 tags 是单个字符串,规整为数组 + if (typeof frontmatter.tags === 'string') { + frontmatter.tags = [frontmatter.tags] + } + + return { frontmatter, body } +} + +function cleanQuotes(val: string): string { + if ( + (val.startsWith('"') && val.endsWith('"')) || + (val.startsWith("'") && val.endsWith("'")) + ) { + return val.slice(1, -1) + } + return val +} diff --git a/components/src/mdx/index.ts b/components/src/mdx/index.ts new file mode 100644 index 0000000..0a4c188 --- /dev/null +++ b/components/src/mdx/index.ts @@ -0,0 +1,8 @@ +export { parseFrontmatter } from './frontmatter' +export type { MdxFrontmatter } from './frontmatter' + +export { MdxHeader } from './mdx-header' +export type { MdxHeaderProps } from './mdx-header' + +export { PreviewMdxViewer, PreviewMdxInlineViewer } from './mdx-viewer' +export type { PreviewMdxViewerProps } from './mdx-viewer' diff --git a/components/src/mdx/mdx-header.tsx b/components/src/mdx/mdx-header.tsx new file mode 100644 index 0000000..648b61c --- /dev/null +++ b/components/src/mdx/mdx-header.tsx @@ -0,0 +1,175 @@ +import { useState } from 'react' +import { + BookOpen, + Boxes, + Calendar, + ChevronDown, + ChevronUp, + CircleDot, + Code, + Compass, + Cpu, + Database, + FileCode, + FileText, + Folder, + Layers, + Lightbulb, + Network, + Settings, + Shield, + ShieldCheck, + Sparkles, + Tag, + Terminal, + Workflow, +} from 'lucide-react' +import type { LucideIcon } from 'lucide-react' +import type { MdxFrontmatter } from './frontmatter' + +const ICON_MAP: Record = { + filetext: FileText, + bookopen: BookOpen, + shield: Shield, + shieldcheck: ShieldCheck, + code: Code, + filecode: FileCode, + cpu: Cpu, + database: Database, + folder: Folder, + layers: Layers, + lightbulb: Lightbulb, + network: Network, + settings: Settings, + terminal: Terminal, + compass: Compass, + sparkles: Sparkles, + workflow: Workflow, + boxes: Boxes, + circledot: CircleDot, +} + +function resolveIcon(iconName?: string): LucideIcon { + if (!iconName) return FileText + const normalized = iconName.toLowerCase().replace(/[-_]/g, '') + return ICON_MAP[normalized] ?? FileText +} + +export interface MdxHeaderProps { + frontmatter: MdxFrontmatter | null + fallbackTitle: string + className?: string +} + +export function MdxHeader({ + frontmatter, + fallbackTitle, + className = '', +}: MdxHeaderProps) { + const [showMeta, setShowMeta] = useState(false) + + const title = frontmatter?.title || fallbackTitle + const description = frontmatter?.description + const tags = frontmatter?.tags + const date = frontmatter?.date || frontmatter?.last_updated + const IconComponent = resolveIcon(frontmatter?.icon) + + // 提取自定义的额外键值对 + const customMeta: Record = {} + if (frontmatter) { + const knownKeys = new Set([ + 'title', + 'description', + 'icon', + 'tags', + 'date', + 'last_updated', + ]) + for (const [key, value] of Object.entries(frontmatter)) { + if (!knownKeys.has(key) && value !== undefined && value !== null) { + customMeta[key] = value + } + } + } + + const hasCustomMeta = Object.keys(customMeta).length > 0 + + return ( +
+
+
+ +
+
+

+ {title} +

+
+
+ + {description && ( +

+ {description} +

+ )} + +
+ {date && ( +
+ + {String(date)} +
+ )} + + {Array.isArray(tags) && tags.length > 0 && ( +
+ + {tags.map((tag, idx) => ( + + {tag} + + ))} +
+ )} + + {hasCustomMeta && ( + + )} +
+ + {hasCustomMeta && showMeta && ( +
+

+ Frontmatter 属性 +

+
+ {Object.entries(customMeta).map(([k, v]) => ( +
+ {k}: + + {typeof v === 'object' ? JSON.stringify(v) : String(v)} + +
+ ))} +
+
+ )} +
+ ) +} diff --git a/components/src/mdx/mdx-viewer.test.tsx b/components/src/mdx/mdx-viewer.test.tsx new file mode 100644 index 0000000..65720ce --- /dev/null +++ b/components/src/mdx/mdx-viewer.test.tsx @@ -0,0 +1,70 @@ +/** @vitest-environment jsdom */ +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { PreviewMdxInlineViewer, PreviewMdxViewer } from './mdx-viewer' + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('PreviewMdxViewer & PreviewMdxInlineViewer', () => { + it('展示加载态并在成功后呈现 Frontmatter 头部与正文', async () => { + const mockMdx = `--- +title: 微明技术文档 +description: 这是 MDX 独立预览测试 +icon: BookOpen +tags: [test, preview] +--- + +# 正文标题 + +这是测试正文内容。` + + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(mockMdx, { status: 200 }), + ) + + render() + + expect(screen.getByText('加载 MDX 文档中…')).toBeTruthy() + + await waitFor(() => { + expect(screen.getByText('微明技术文档')).toBeTruthy() + expect(screen.getByText('这是 MDX 独立预览测试')).toBeTruthy() + expect(screen.getByText('test')).toBeTruthy() + expect(screen.getByText('preview')).toBeTruthy() + expect(screen.getByText('这是测试正文内容。')).toBeTruthy() + }) + }) + + it('网络请求失败时展示错误提示与重试按钮', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new Error('Network error')) + + render() + + await waitFor(() => { + expect(screen.getByText(/加载失败: Network error/)).toBeTruthy() + expect(screen.getByText('重试')).toBeTruthy() + }) + }) + + it('PreviewMdxInlineViewer 应正常内联渲染', async () => { + const mockMdx = `--- +title: 内联 MDX 卡片 +--- + +内联内容段落` + + vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response(mockMdx, { status: 200 }), + ) + + render() + + await waitFor(() => { + expect(screen.getByText('内联 MDX 卡片')).toBeTruthy() + expect(screen.getByText('内联内容段落')).toBeTruthy() + }) + }) +}) diff --git a/components/src/mdx/mdx-viewer.tsx b/components/src/mdx/mdx-viewer.tsx new file mode 100644 index 0000000..db343f2 --- /dev/null +++ b/components/src/mdx/mdx-viewer.tsx @@ -0,0 +1,181 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { ListTree, RefreshCw } from 'lucide-react' +import { Markdown, proseArticle, TableOfContents } from '../markdown' +import { parseFrontmatter } from './frontmatter' +import { MdxHeader } from './mdx-header' + +export interface PreviewMdxViewerProps { + src: string + filename: string + variant?: 'page' | 'inline' +} + +export function PreviewMdxViewer({ + src, + filename, + variant = 'page', +}: PreviewMdxViewerProps) { + const [source, setSource] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [fetchIndex, setFetchIndex] = useState(0) + const [mobileTocOpen, setMobileTocOpen] = useState(false) + + const reload = useCallback(() => { + setFetchIndex((i) => i + 1) + }, []) + + useEffect(() => { + const ac = new AbortController() + setLoading(true) + setError(null) + + void (async () => { + try { + const rawUrl = src.includes('?') ? `${src}&raw=1` : `${src}?raw=1` + const res = await fetch(rawUrl, { signal: ac.signal }) + if (!res.ok) { + throw new Error(`HTTP ${res.status}`) + } + const text = await res.text() + if (!ac.signal.aborted) { + setSource(text) + setLoading(false) + } + } catch (err) { + if (ac.signal.aborted) return + setError(err instanceof Error ? err.message : '读取 MDX 文件失败') + setLoading(false) + } + })() + + return () => ac.abort() + }, [src, fetchIndex]) + + const { frontmatter, cleanBody, fallbackTitle } = useMemo(() => { + if (source === null) { + return { frontmatter: null, cleanBody: '', fallbackTitle: filename } + } + const { frontmatter: fm, body } = parseFrontmatter(source) + + // 尝试从文件名推导备选标题(去除扩展名与路径) + const baseName = filename.split(/[/\\]/).pop() ?? filename + const nameWithoutExt = baseName.replace(/\.[^/.]+$/, '') + + // 检查正文开头是否存在重复的一级标题 + const trimmedBody = body.trimStart() + const firstH1Match = trimmedBody.match(/^#\s+(.+)$/m) + let bodyTitle: string | null = null + let processedBody = body + + if (firstH1Match) { + bodyTitle = firstH1Match[1].trim() + // 若 frontmatter 明确提供了 title 且与正文首个 # 相同,或正文首行即为 # 标题, + // 剥离该行以避免双重主标题显示 + if ( + fm?.title && + fm.title.trim().toLowerCase() === bodyTitle.toLowerCase() + ) { + processedBody = trimmedBody.replace(/^#\s+.+$/m, '').trimStart() + } + } + + const title = fm?.title || bodyTitle || nameWithoutExt + + return { + frontmatter: fm, + cleanBody: processedBody, + fallbackTitle: title, + } + }, [source, filename]) + + if (loading) { + return ( +
+

加载 MDX 文档中…

+
+ ) + } + + if (error || source === null) { + return ( +
+

加载失败: {error || '无法读取文件内容'}

+ +
+ ) + } + + if (variant === 'inline') { + return ( +
+ +
+ {cleanBody} +
+
+ ) + } + + return ( +
+
+
+ +
+ {cleanBody} +
+
+ + {/* 桌面端右侧固定目录 */} + + + {/* 移动端目录悬浮开关 */} +
+ + {mobileTocOpen && ( +
+

目录大纲

+ +
+ )} +
+
+
+ ) +} + +export function PreviewMdxInlineViewer(props: { + src: string + filename: string +}) { + return +} diff --git a/internal/constant/preview.go b/internal/constant/preview.go index 5e4f81e..feb6a0c 100644 --- a/internal/constant/preview.go +++ b/internal/constant/preview.go @@ -13,6 +13,7 @@ const ( PreviewMimeJS = "application/javascript; charset=utf-8" // JavaScript 文件 PreviewMimeJSON = "application/json; charset=utf-8" // JSON 文件 PreviewMimeMarkdown = "text/markdown; charset=utf-8" // Markdown 文件 + PreviewMimeMDX = "text/mdx; charset=utf-8" // MDX 预览文档 PreviewMimeSVG = "image/svg+xml" // SVG 文件(SVG 自声明编码,不加 charset) PreviewMimeTSX = "text/typescript-jsx; charset=utf-8" // TSX 组件文件 PreviewMimeJSX = "text/jsx; charset=utf-8" // JSX 组件文件 diff --git a/internal/handler/preview.go b/internal/handler/preview.go index 7d4633e..08b6d4b 100644 --- a/internal/handler/preview.go +++ b/internal/handler/preview.go @@ -2,7 +2,6 @@ package handler import ( "net/http" - "path/filepath" "strings" xResult "github.com/bamboo-services/bamboo-base-go/major/result" @@ -10,6 +9,7 @@ import ( apiCommon "github.com/xiaolfeng/Lumina/api/common" apiPages "github.com/xiaolfeng/Lumina/api/pages" apiPreview "github.com/xiaolfeng/Lumina/api/preview" + "github.com/xiaolfeng/Lumina/internal/logic" ) // 确保 apiCommon 包被编译器识别(swag 注释依赖此导入) @@ -288,26 +288,9 @@ func (h *PreviewHandler) ServePreviewPath(ctx *gin.Context) { return } entry := "" - // 1. 优先匹配 HTML - for _, file := range files { - if strings.HasPrefix(strings.ToLower(file.MimeType), "text/html") { - entry = file.Filename - break - } - } - // 2. 若无 HTML,寻找 TSX / JSX 入口 - if entry == "" { - for _, file := range files { - ext := strings.ToLower(filepath.Ext(file.Filename)) - if ext == ".tsx" || ext == ".jsx" { - entry = file.Filename - if strings.HasPrefix(strings.ToLower(file.Filename), "app.") || strings.HasPrefix(strings.ToLower(file.Filename), "index.") { - break - } - } - } - } - if entry == "" && len(files) > 0 { + if fileEntry := logic.FindPreviewEntry(files); fileEntry != nil { + entry = fileEntry.Filename + } else if len(files) > 0 { entry = files[0].Filename } if entry == "" { diff --git a/internal/handler/serve.go b/internal/handler/serve.go index 42bf299..4c70051 100644 --- a/internal/handler/serve.go +++ b/internal/handler/serve.go @@ -21,7 +21,7 @@ const navigateShim = `