import { Fragment, type ReactNode } from 'react'
/**
* 轻量 Markdown 渲染器 — 零依赖,专为 AI 财务分析报告设计。
*
* 支持的语法(AI 财务分析提示词约束的子集,足够用):
* - 标题 # ## ### ####
* - 加粗 **text**
* - 行内代码 `code`
* - 无序列表 - / *
* - 有序列表 1.
* - 表格 | a | b |
* - 引用 >
* - 分隔线 --- / ***
* - 段落
*
* 不追求完整 GFM,只覆盖 AI 报告会产出的结构。
*/
// ===== 行内格式:加粗 / 行内代码 / 星号评级 =====
function renderInline(text: string, keyBase: string): ReactNode[] {
const nodes: ReactNode[] = []
// 正则:匹配 **加粗** 或 `代码` 或 ★ 评级
const re = /(\*\*([^*]+)\*\*)|(`([^`]+)`)/g
let last = 0
let m: RegExpExecArray | null
let i = 0
while ((m = re.exec(text)) !== null) {
if (m.index > last) nodes.push({text.slice(last, m.index)})
if (m[1]) {
// 加粗
nodes.push({m[2]})
} else if (m[3]) {
// 行内代码
nodes.push(
{m[4]}
,
)
}
last = m.index + m[0].length
i++
}
if (last < text.length) nodes.push({text.slice(last)})
return nodes
}
// ===== 表格解析 =====
function parseTable(lines: string[], start: number): { rows: string[][]; consumed: number } | null {
// 找到连续的表格行(以 | 开头)
const tableLines: string[] = []
let idx = start
while (idx < lines.length && lines[idx].trim().startsWith('|')) {
tableLines.push(lines[idx].trim())
idx++
}
if (tableLines.length < 2) return null
// 第二行必须是分隔行 |---|---|
if (!/^|[\s-:|]+$/.test(tableLines[1]) && !tableLines[1].split('|').every(c => /^[\s-:]*$/.test(c))) {
return null
}
const parseRow = (line: string) =>
line.replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim())
const header = parseRow(tableLines[0])
const body = tableLines.slice(2).map(parseRow)
return { rows: [header, ...body], consumed: tableLines.length }
}
// ===== 主渲染 =====
export function MarkdownRenderer({ content }: { content: string }) {
const lines = content.replace(/\r\n/g, '\n').split('\n')
const blocks: ReactNode[] = []
let i = 0
let key = 0
while (i < lines.length) {
const line = lines[i]
const trimmed = line.trim()
// 空行
if (!trimmed) {
i++
continue
}
// 分隔线
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
blocks.push(
)
i++
continue
}
// 标题
const hMatch = trimmed.match(/^(#{1,4})\s+(.+)$/)
if (hMatch) {
const level = hMatch[1].length
const text = hMatch[2]
const sizeCls = level === 1 ? 'text-base' : level === 2 ? 'text-sm' : 'text-xs'
const mtCls = level <= 2 ? 'mt-6' : 'mt-5'
blocks.push(
{renderInline(text, `h-${key}`)}
,
)
i++
continue
}
// 引用
if (trimmed.startsWith('>')) {
const quoteLines: string[] = []
while (i < lines.length && lines[i].trim().startsWith('>')) {
quoteLines.push(lines[i].trim().replace(/^>\s?/, ''))
i++
}
blocks.push(
{renderInline(quoteLines.join(' '), `q-${key}`)}
,
)
continue
}
// 表格
if (trimmed.startsWith('|')) {
const table = parseTable(lines, i)
if (table) {
const [header, ...body] = table.rows
const ncol = header.length
blocks.push(
{/* 首列(维度)较窄;末列(判断/说明)最宽并允许折行 */}
{Array.from({ length: ncol - 1 }).map((_, ci) => (
))}
{header.map((cell, ci) => (
|
{renderInline(cell, `th-${key}-${ci}`)}
|
))}
{body.map((row, ri) => (
{row.map((cell, ci) => (
|
{renderInline(cell, `td-${key}-${ri}-${ci}`)}
|
))}
))}
,
)
i += table.consumed
continue
}
}
// 无序列表
if (/^[-*]\s+/.test(trimmed)) {
const items: string[] = []
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
items.push(lines[i].replace(/^\s*[-*]\s+/, ''))
i++
}
blocks.push(
{items.map((item, ii) => (
-
{renderInline(item, `li-${key}-${ii}`)}
))}
,
)
continue
}
// 有序列表
if (/^\d+\.\s+/.test(trimmed)) {
const items: string[] = []
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
items.push(lines[i].replace(/^\s*\d+\.\s+/, ''))
i++
}
blocks.push(
{items.map((item, ii) => (
-
{ii + 1}
{renderInline(item, `ol-${key}-${ii}`)}
))}
,
)
continue
}
// 普通段落
blocks.push(
{renderInline(trimmed, `p-${key}`)}
,
)
i++
}
// 注意:外层不加 space-y-*,否则会覆盖各块自己的 margin-top 导致间距失效。
// 让各块的 my-* 自然叠加(margin collapse),间距更可控。
return {blocks}
}