- 布局:Layout 使用 contained(1380)+gutter20+siderWidth240+gap20、solid 60px 页头、整屏内部滚动 - 搜索框恢复悬浮居中;侧栏与内容恢复 52/88px 顶部偏移 - 新增并使用 @stash/ui 的 Cover(海报 2:3 / 图文 4:3 / 宽图)替换手写封面 - 头像恢复 64px 品牌底色;正文恢复 14/1.9;详情标题、标签、引用、统计、跳转按原版还原 - 分区卡 padding 20、分区标题 16、网格间距 12/16 对齐原值
134 lines
3.5 KiB
TypeScript
134 lines
3.5 KiB
TypeScript
import { Paragraph, Title } from '@stash/ui'
|
|
import type { ReactNode } from 'react'
|
|
|
|
/**
|
|
* 极简 Markdown 渲染:只覆盖文章正文实际用到的子集。
|
|
*
|
|
* 支持:
|
|
* - `## 小标题`(1~6 级井号都渲染成同一级小标题,标题层级由版式决定)
|
|
* - 空行分段
|
|
* - 单独一行的 `` 作为配图
|
|
* - 行内 `**粗体**`、`` `代码` ``、`[文字](链接)`
|
|
*
|
|
* 不支持列表、表格、引用块——这里渲染的是文章正文,不是通用 Markdown 编辑器。
|
|
*/
|
|
|
|
type Block =
|
|
| { kind: 'heading'; text: string }
|
|
| { kind: 'image'; alt: string; src: string }
|
|
| { kind: 'paragraph'; text: string }
|
|
|
|
/** 行内语法:粗体 / 代码 / 链接;其余按纯文本处理 */
|
|
const INLINE_PATTERN = /(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\([^)]+\))/g
|
|
|
|
/** 把 Markdown 文本切成块;规则尽量简单,宁可少解析也不误伤正文 */
|
|
function parseBlocks(markdown: string): Block[] {
|
|
const blocks: Block[] = []
|
|
const lines = markdown.replace(/\r\n/g, '\n').split('\n')
|
|
|
|
let paragraph: string[] = []
|
|
|
|
// 空行或遇到标题/图片时,把攒着的行收成一个段落
|
|
const flush = () => {
|
|
if (paragraph.length === 0) return
|
|
blocks.push({ kind: 'paragraph', text: paragraph.join(' ') })
|
|
paragraph = []
|
|
}
|
|
|
|
for (const raw of lines) {
|
|
const line = raw.trim()
|
|
|
|
if (line === '') {
|
|
flush()
|
|
continue
|
|
}
|
|
|
|
const heading = /^#{1,6}\s+(.*)$/.exec(line)
|
|
if (heading !== null) {
|
|
flush()
|
|
blocks.push({ kind: 'heading', text: heading[1] })
|
|
continue
|
|
}
|
|
|
|
const image = /^!\[([^\]]*)\]\(([^)\s]+)\)$/.exec(line)
|
|
if (image !== null) {
|
|
flush()
|
|
blocks.push({ kind: 'image', alt: image[1], src: image[2] })
|
|
continue
|
|
}
|
|
|
|
paragraph.push(line)
|
|
}
|
|
|
|
flush()
|
|
return blocks
|
|
}
|
|
|
|
/** 渲染一行里的行内语法 */
|
|
function renderInline(text: string): ReactNode[] {
|
|
return text
|
|
.split(INLINE_PATTERN)
|
|
.filter((part) => part !== '')
|
|
.map((part, index) => {
|
|
if (part.startsWith('**') && part.endsWith('**')) {
|
|
return <strong key={index}>{part.slice(2, -2)}</strong>
|
|
}
|
|
|
|
if (part.startsWith('`') && part.endsWith('`')) {
|
|
return <code key={index}>{part.slice(1, -1)}</code>
|
|
}
|
|
|
|
const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(part)
|
|
if (link !== null) {
|
|
return (
|
|
<a key={index} href={link[2]} target="_blank" rel="noreferrer">
|
|
{link[1]}
|
|
</a>
|
|
)
|
|
}
|
|
|
|
return <span key={index}>{part}</span>
|
|
})
|
|
}
|
|
|
|
type ArticleBodyProps = {
|
|
/** Markdown 正文;为空时组件渲染空内容 */
|
|
markdown?: string
|
|
}
|
|
|
|
export function ArticleBody({ markdown }: ArticleBodyProps) {
|
|
if (markdown === undefined || markdown.trim() === '') return null
|
|
|
|
return (
|
|
<>
|
|
{parseBlocks(markdown).map((block, index) => {
|
|
if (block.kind === 'heading') {
|
|
return (
|
|
<Title level={2} as="h3" key={index}>
|
|
{renderInline(block.text)}
|
|
</Title>
|
|
)
|
|
}
|
|
|
|
if (block.kind === 'image') {
|
|
return (
|
|
<img
|
|
className="article-body-image"
|
|
key={index}
|
|
src={block.src}
|
|
alt={block.alt}
|
|
loading="lazy"
|
|
/>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Paragraph tone="sub" size="md" leading="relaxed" key={index}>
|
|
{renderInline(block.text)}
|
|
</Paragraph>
|
|
)
|
|
})}
|
|
</>
|
|
)
|
|
}
|