Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions components/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
// @lumina/components - shared UI package entry point
export * from './mdx'

80 changes: 80 additions & 0 deletions components/src/mdx/frontmatter.test.ts
Original file line number Diff line number Diff line change
@@ -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('')
})
})
124 changes: 124 additions & 0 deletions components/src/mdx/frontmatter.ts
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 8 additions & 0 deletions components/src/mdx/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Loading
Loading