2026-03-18 14:09:06 +08:00
|
|
|
import type { ParsedDoc, DocMetadata, DocSection, DocTable, DocCodeBlock } from './types'
|
|
|
|
|
|
|
|
|
|
const METADATA_REGEX = /^\*\*([^\*]+)\*\*:\s*(.+)$/
|
|
|
|
|
const TABLE_REGEX = /^\|(.+)\|$/
|
|
|
|
|
const CODE_BLOCK_REGEX = /^```(\w*)$/
|
|
|
|
|
const HEADING_REGEX = /^(#{1,6})\s+(.+)$/
|
|
|
|
|
const HR_REGEX = /^---+$/
|
|
|
|
|
const REFERENCE_REGEX = /@see\s+([^\s]+)/g
|
|
|
|
|
|
|
|
|
|
export function parseMarkdown(content: string): ParsedDoc {
|
2026-03-18 16:13:57 +08:00
|
|
|
if (!content || typeof content !== 'string') {
|
|
|
|
|
return { title: '', metadata: {}, sections: [], references: [] };
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 14:09:06 +08:00
|
|
|
const lines = content.split('\n')
|
|
|
|
|
const result: ParsedDoc = {
|
|
|
|
|
title: '',
|
|
|
|
|
metadata: {},
|
|
|
|
|
sections: [],
|
|
|
|
|
references: [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const references: string[] = []
|
|
|
|
|
let metadata: DocMetadata = {}
|
|
|
|
|
let sections: DocSection[] = []
|
|
|
|
|
let currentSection: DocSection | null = null
|
|
|
|
|
let currentContent: Array<{ type: 'text' | 'table' | 'code'; data: string | DocTable | DocCodeBlock }> = []
|
|
|
|
|
let inCodeBlock = false
|
|
|
|
|
let codeBlockLanguage = ''
|
|
|
|
|
let codeBlockLines: string[] = []
|
|
|
|
|
let tableBuffer: string[] = []
|
|
|
|
|
let inTable = false
|
|
|
|
|
|
2026-03-18 18:50:26 +08:00
|
|
|
try {
|
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
|
|
|
const line = lines[i]
|
|
|
|
|
const trimmedLine = line.trim()
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const refMatch = trimmedLine.match(REFERENCE_REGEX)
|
|
|
|
|
if (refMatch) {
|
|
|
|
|
let match
|
|
|
|
|
const refRegex = /@see\s+([^\s]+)/g
|
|
|
|
|
while ((match = refRegex.exec(trimmedLine)) !== null) {
|
|
|
|
|
references.push(match[1])
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-18 14:09:06 +08:00
|
|
|
|
2026-03-18 18:50:26 +08:00
|
|
|
const codeMatch = line.match(CODE_BLOCK_REGEX)
|
|
|
|
|
if (codeMatch) {
|
|
|
|
|
if (currentSection) {
|
|
|
|
|
currentSection.content = [...currentContent]
|
|
|
|
|
sections.push(currentSection)
|
|
|
|
|
currentSection = null
|
|
|
|
|
}
|
|
|
|
|
inCodeBlock = true
|
|
|
|
|
codeBlockLanguage = codeMatch[1] || ''
|
|
|
|
|
currentContent = []
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-03-18 14:09:06 +08:00
|
|
|
|
2026-03-18 18:50:26 +08:00
|
|
|
if (inCodeBlock) {
|
|
|
|
|
if (trimmedLine === '```' || trimmedLine.startsWith('```')) {
|
|
|
|
|
currentContent.push({
|
|
|
|
|
type: 'code',
|
|
|
|
|
data: { language: codeBlockLanguage, code: codeBlockLines.join('\n') },
|
|
|
|
|
} as { type: 'code'; data: DocCodeBlock })
|
|
|
|
|
codeBlockLines = []
|
|
|
|
|
codeBlockLanguage = ''
|
|
|
|
|
inCodeBlock = false
|
|
|
|
|
|
|
|
|
|
if (!currentSection) {
|
|
|
|
|
currentSection = { title: '', level: 2, content: [] }
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
codeBlockLines.push(line)
|
|
|
|
|
}
|
|
|
|
|
continue
|
2026-03-18 14:09:06 +08:00
|
|
|
}
|
|
|
|
|
|
2026-03-18 18:50:26 +08:00
|
|
|
if (HR_REGEX.test(trimmedLine)) {
|
|
|
|
|
if (inTable && tableBuffer.length > 0) {
|
|
|
|
|
const table = parseTable(tableBuffer)
|
|
|
|
|
if (table) {
|
|
|
|
|
currentContent.push({ type: 'table', data: table })
|
|
|
|
|
}
|
|
|
|
|
tableBuffer = []
|
|
|
|
|
inTable = false
|
|
|
|
|
}
|
|
|
|
|
continue
|
2026-03-18 14:09:06 +08:00
|
|
|
}
|
|
|
|
|
|
2026-03-18 18:50:26 +08:00
|
|
|
if (!result.title && line.startsWith('# ')) {
|
|
|
|
|
result.title = line.slice(2).trim()
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const headingMatch = line.match(HEADING_REGEX)
|
|
|
|
|
if (headingMatch) {
|
|
|
|
|
if (currentSection) {
|
|
|
|
|
currentSection.content = [...currentContent]
|
|
|
|
|
sections.push(currentSection)
|
|
|
|
|
}
|
|
|
|
|
const level = headingMatch[1].length
|
|
|
|
|
const title = headingMatch[2].trim()
|
|
|
|
|
currentSection = { title, level, content: [] }
|
|
|
|
|
currentContent = []
|
|
|
|
|
inTable = false
|
|
|
|
|
tableBuffer = []
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const metaMatch = trimmedLine.match(METADATA_REGEX)
|
|
|
|
|
if (metaMatch) {
|
|
|
|
|
const key = metaMatch[1].toLowerCase()
|
|
|
|
|
const value = metaMatch[2].trim()
|
|
|
|
|
if (key === 'namespace') metadata.namespace = value
|
|
|
|
|
else if (key === 'description') metadata.description = value
|
|
|
|
|
else if (key === 'type') metadata.type = value
|
|
|
|
|
else if (key === 'inherits') metadata.inherits = value
|
|
|
|
|
else if (key === 'package') metadata.package = value
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-03-18 14:09:06 +08:00
|
|
|
|
2026-03-18 18:50:26 +08:00
|
|
|
const tableMatch = line.match(TABLE_REGEX)
|
|
|
|
|
if (tableMatch) {
|
|
|
|
|
tableBuffer.push(trimmedLine)
|
|
|
|
|
inTable = true
|
|
|
|
|
continue
|
|
|
|
|
} else if (inTable && tableBuffer.length > 0) {
|
|
|
|
|
const table = parseTable(tableBuffer)
|
|
|
|
|
if (table) {
|
|
|
|
|
currentContent.push({ type: 'table', data: table })
|
|
|
|
|
}
|
|
|
|
|
tableBuffer = []
|
|
|
|
|
inTable = false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (trimmedLine) {
|
|
|
|
|
currentContent.push({ type: 'text', data: trimmedLine })
|
|
|
|
|
}
|
|
|
|
|
} catch (lineError) {
|
|
|
|
|
console.warn('Error parsing line', i, ':', lineError)
|
2026-03-18 14:09:06 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 18:50:26 +08:00
|
|
|
// 结束未闭合的代码块
|
|
|
|
|
if (inCodeBlock && codeBlockLines.length > 0) {
|
|
|
|
|
currentContent.push({
|
|
|
|
|
type: 'code',
|
|
|
|
|
data: { language: codeBlockLanguage, code: codeBlockLines.join('\n') },
|
|
|
|
|
} as { type: 'code'; data: DocCodeBlock })
|
2026-03-18 14:09:06 +08:00
|
|
|
}
|
|
|
|
|
|
2026-03-18 18:50:26 +08:00
|
|
|
// 结束未闭合的表格
|
|
|
|
|
if (inTable && tableBuffer.length > 0) {
|
2026-03-18 14:09:06 +08:00
|
|
|
const table = parseTable(tableBuffer)
|
|
|
|
|
if (table) {
|
|
|
|
|
currentContent.push({ type: 'table', data: table })
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-18 18:50:26 +08:00
|
|
|
if (currentSection) {
|
|
|
|
|
currentSection.content = [...currentContent]
|
|
|
|
|
sections.push(currentSection)
|
2026-03-18 14:09:06 +08:00
|
|
|
}
|
|
|
|
|
|
2026-03-18 18:50:26 +08:00
|
|
|
result.metadata = metadata
|
|
|
|
|
result.sections = sections
|
|
|
|
|
result.references = references
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('Markdown parse error:', error)
|
|
|
|
|
return {
|
|
|
|
|
title: '',
|
|
|
|
|
metadata: {},
|
|
|
|
|
sections: [],
|
|
|
|
|
references: [],
|
|
|
|
|
parseError: true,
|
|
|
|
|
rawContent: content
|
|
|
|
|
}
|
2026-03-18 14:09:06 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseTable(tableLines: string[]): DocTable | null {
|
2026-03-18 18:50:26 +08:00
|
|
|
try {
|
|
|
|
|
if (tableLines.length < 1) return null
|
|
|
|
|
|
|
|
|
|
const headers = tableLines[0].split('|').filter(h => h.trim()).map(h => h.trim())
|
|
|
|
|
const rows: string[][] = []
|
|
|
|
|
|
|
|
|
|
for (let i = 1; i < tableLines.length; i++) {
|
|
|
|
|
const line = tableLines[i]
|
|
|
|
|
const cleaned = line.replace(/\|/g, '').trim()
|
|
|
|
|
const isSeparator = /^[-\s]+$/.test(cleaned)
|
|
|
|
|
if (isSeparator) continue
|
|
|
|
|
|
|
|
|
|
const cells = line.split('|').filter(c => c.trim()).map(c => c.trim())
|
|
|
|
|
if (cells.length > 0) {
|
|
|
|
|
rows.push(cells)
|
|
|
|
|
}
|
2026-03-18 14:09:06 +08:00
|
|
|
}
|
|
|
|
|
|
2026-03-18 18:50:26 +08:00
|
|
|
return { headers, rows }
|
|
|
|
|
} catch {
|
|
|
|
|
return null
|
|
|
|
|
}
|
2026-03-18 14:09:06 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getDisplayName(filename: string): string {
|
|
|
|
|
const name = filename.replace(/\.md$/, '')
|
|
|
|
|
return name
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function buildFileTree(files: string[], basePath: string): import('./types').DocFile[] {
|
|
|
|
|
const tree: Map<string, import('./types').DocFile> = new Map()
|
|
|
|
|
|
|
|
|
|
for (const file of files) {
|
|
|
|
|
const relativePath = file.split('/').filter(Boolean).join('/')
|
|
|
|
|
const parts = relativePath.split('/')
|
|
|
|
|
|
|
|
|
|
let currentPath = ''
|
|
|
|
|
for (let i = 0; i < parts.length; i++) {
|
|
|
|
|
const part = parts[i]
|
|
|
|
|
const isLast = i === parts.length - 1
|
|
|
|
|
currentPath = currentPath ? `${currentPath}/${part}` : part
|
|
|
|
|
|
|
|
|
|
if (!tree.has(currentPath)) {
|
|
|
|
|
tree.set(currentPath, {
|
|
|
|
|
name: part,
|
|
|
|
|
path: `${basePath}/${currentPath}`,
|
|
|
|
|
relativePath: currentPath,
|
|
|
|
|
isDir: !isLast,
|
|
|
|
|
children: isLast ? undefined : [],
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const parentPath = currentPath.split('/').slice(0, -1).join('/')
|
|
|
|
|
if (parentPath && tree.has(parentPath)) {
|
|
|
|
|
const parent = tree.get(parentPath)!
|
|
|
|
|
if (parent.children) {
|
|
|
|
|
const existing = parent.children.find(c => c.name === part)
|
|
|
|
|
if (!existing) {
|
|
|
|
|
parent.children.push(tree.get(currentPath)!)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rootFiles: import('./types').DocFile[] = []
|
|
|
|
|
for (const [path, file] of tree) {
|
|
|
|
|
const parentPath = path.split('/').slice(0, -1).join('/')
|
|
|
|
|
if (!parentPath) {
|
|
|
|
|
rootFiles.push(file)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sortFiles = (files: import('./types').DocFile[]): import('./types').DocFile[] => {
|
|
|
|
|
return files.sort((a, b) => {
|
|
|
|
|
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
|
|
|
|
|
return a.name.localeCompare(b.name)
|
|
|
|
|
}).map(f => {
|
|
|
|
|
if (f.children) {
|
|
|
|
|
f.children = sortFiles(f.children)
|
|
|
|
|
}
|
|
|
|
|
return f
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return sortFiles(rootFiles)
|
|
|
|
|
}
|