283 lines
8.9 KiB
TypeScript
283 lines
8.9 KiB
TypeScript
import { Marked } from 'marked'
|
|
import type { CSSProperties } from 'react'
|
|
import type { Tokens } from 'marked'
|
|
|
|
// 将样式对象转换为 CSS 字符串
|
|
function cssPropertiesToString(style: StyleOptions = {}): string {
|
|
return Object.entries(style)
|
|
.filter(([_, value]) => value !== undefined)
|
|
.map(([key, value]) => {
|
|
// 转换驼峰命名为连字符命名
|
|
const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase()
|
|
return `${cssKey}: ${value}`
|
|
})
|
|
.join(';')
|
|
}
|
|
|
|
// 将基础样式选项转换为 CSS 字符串
|
|
function baseStylesToString(base: RendererOptions['base'] = {}): string {
|
|
const styles: string[] = []
|
|
|
|
if (base.primaryColor) {
|
|
styles.push(`--md-primary-color: ${base.primaryColor}`)
|
|
}
|
|
if (base.textAlign) {
|
|
styles.push(`text-align: ${base.textAlign}`)
|
|
}
|
|
if (base.lineHeight) {
|
|
styles.push(`line-height: ${base.lineHeight}`)
|
|
}
|
|
if (base.fontSize) {
|
|
styles.push(`font-size: ${base.fontSize}`)
|
|
}
|
|
|
|
return styles.join(';')
|
|
}
|
|
|
|
// 预处理函数
|
|
function preprocessMarkdown(markdown: string): string {
|
|
return markdown
|
|
// 处理 ** 语法,但排除已经是 HTML 的部分
|
|
.replace(/(?<!<[^>]*)\*\*([^*]+)\*\*(?![^<]*>)/g, '<strong>$1</strong>')
|
|
// 处理无序列表的 - 标记,但排除代码块内的部分
|
|
.replace(/^(?!\s*```)([ \t]*)-\s+/gm, '$1• ')
|
|
}
|
|
|
|
// Initialize marked instance
|
|
const marked = new Marked()
|
|
|
|
// 创建基础渲染器
|
|
const baseRenderer = new marked.Renderer()
|
|
|
|
// 重写 strong 渲染器
|
|
baseRenderer.strong = function(text) {
|
|
return `<strong>${text}</strong>`
|
|
}
|
|
|
|
// 应用配置和渲染器
|
|
marked.setOptions({
|
|
gfm: true,
|
|
breaks: true,
|
|
async: false,
|
|
pedantic: false,
|
|
renderer: baseRenderer
|
|
})
|
|
|
|
const defaultOptions: RendererOptions = {
|
|
base: {
|
|
primaryColor: '#333333',
|
|
textAlign: 'left',
|
|
lineHeight: '1.75',
|
|
fontSize: '15px',
|
|
themeColor: '#1a1a1a'
|
|
},
|
|
block: {
|
|
h1: { fontSize: '24px' },
|
|
h2: { fontSize: '20px' },
|
|
h3: { fontSize: '18px' },
|
|
p: { fontSize: '15px', color: '#333333' },
|
|
code_pre: { fontSize: '14px', color: '#333333' },
|
|
blockquote: { fontSize: '15px', color: '#666666' }
|
|
},
|
|
inline: {
|
|
link: { color: '#576b95' },
|
|
codespan: { color: '#333333' },
|
|
em: { color: '#666666' }
|
|
}
|
|
}
|
|
|
|
export function convertToWechat(markdown: string, options: RendererOptions = defaultOptions): string {
|
|
// 创建渲染器
|
|
const customRenderer = new marked.Renderer()
|
|
|
|
// 继承基础渲染器
|
|
Object.setPrototypeOf(customRenderer, baseRenderer)
|
|
|
|
// 合并选项
|
|
const mergedOptions = {
|
|
base: { ...defaultOptions.base, ...options.base },
|
|
block: { ...defaultOptions.block, ...options.block },
|
|
inline: { ...defaultOptions.inline, ...options.inline }
|
|
}
|
|
|
|
customRenderer.heading = function({ text, depth }: Tokens.Heading) {
|
|
const style = {
|
|
...mergedOptions.block?.[`h${depth}`],
|
|
color: mergedOptions.base?.themeColor // 使用主题颜色
|
|
}
|
|
const styleStr = cssPropertiesToString(style)
|
|
const tokens = marked.Lexer.lexInline(text)
|
|
const content = marked.Parser.parseInline(tokens, { renderer: customRenderer })
|
|
return `<h${depth}${styleStr ? ` style="${styleStr}"` : ''}>${content}</h${depth}>`
|
|
}
|
|
|
|
customRenderer.paragraph = function({ text }: Tokens.Paragraph) {
|
|
const style = mergedOptions.block?.p
|
|
const styleStr = cssPropertiesToString(style)
|
|
const tokens = marked.Lexer.lexInline(text)
|
|
const content = marked.Parser.parseInline(tokens, { renderer: customRenderer })
|
|
return `<p${styleStr ? ` style="${styleStr}"` : ''}>${content}</p>`
|
|
}
|
|
|
|
customRenderer.blockquote = function({ text }: Tokens.Blockquote) {
|
|
const style = mergedOptions.block?.blockquote
|
|
const styleStr = cssPropertiesToString(style)
|
|
return `<blockquote${styleStr ? ` style="${styleStr}"` : ''}>${text}</blockquote>`
|
|
}
|
|
|
|
customRenderer.code = function({ text, lang }: Tokens.Code) {
|
|
const style = mergedOptions.block?.code_pre
|
|
const styleStr = cssPropertiesToString(style)
|
|
return `<pre${styleStr ? ` style="${styleStr}"` : ''}><code class="language-${lang || ''}">${text}</code></pre>`
|
|
}
|
|
|
|
customRenderer.codespan = function({ text }: Tokens.Codespan) {
|
|
const style = mergedOptions.inline?.codespan
|
|
const styleStr = cssPropertiesToString(style)
|
|
return `<code${styleStr ? ` style="${styleStr}"` : ''}>${text}</code>`
|
|
}
|
|
|
|
customRenderer.em = function({ text }: Tokens.Em) {
|
|
const style = mergedOptions.inline?.em
|
|
const styleStr = cssPropertiesToString(style)
|
|
return `<em${styleStr ? ` style="${styleStr}"` : ''}>${text}</em>`
|
|
}
|
|
|
|
customRenderer.strong = function({ text }: Tokens.Strong) {
|
|
const style = mergedOptions.inline?.strong
|
|
const styleStr = cssPropertiesToString(style)
|
|
return `<strong${styleStr ? ` style="${styleStr}"` : ''}>${text}</strong>`
|
|
}
|
|
|
|
customRenderer.link = function({ href, title, text }: Tokens.Link) {
|
|
const style = mergedOptions.inline?.link
|
|
const styleStr = cssPropertiesToString(style)
|
|
return `<a href="${href}"${title ? ` title="${title}"` : ''}${styleStr ? ` style="${styleStr}"` : ''}>${text}</a>`
|
|
}
|
|
|
|
customRenderer.image = function({ href, title, text }: Tokens.Image) {
|
|
const style = mergedOptions.block?.image
|
|
const styleStr = cssPropertiesToString(style)
|
|
return `<img src="${href}"${title ? ` title="${title}"` : ''} alt="${text}"${styleStr ? ` style="${styleStr}"` : ''} />`
|
|
}
|
|
|
|
// 重写 list 方法
|
|
customRenderer.list = function(token: Tokens.List): string {
|
|
const tag = token.ordered ? 'ol' : 'ul'
|
|
try {
|
|
const style = mergedOptions.block?.[token.ordered ? 'ol' : 'ul']
|
|
const styleStr = cssPropertiesToString(style)
|
|
const startAttr = token.ordered && token.start !== 1 ? ` start="${token.start}"` : ''
|
|
|
|
return `<${tag}${startAttr}${styleStr ? ` style="${styleStr}"` : ''}>${token.items.map(item => customRenderer.listitem(item)).join('')}</${tag}>`
|
|
} catch (error) {
|
|
console.error(`Error rendering list: ${error}`)
|
|
return `<${tag}>${token.items.map(item => customRenderer.listitem(item)).join('')}</${tag}>`
|
|
}
|
|
}
|
|
|
|
// 重写 listitem 方法
|
|
customRenderer.listitem = function(item: Tokens.ListItem) {
|
|
try {
|
|
const style = mergedOptions.inline?.listitem
|
|
const styleStr = cssPropertiesToString(style)
|
|
|
|
// 移除列表项开头的破折号和空格
|
|
let itemText = item.text.replace(/^- /, '')
|
|
|
|
// 处理任务列表项
|
|
if (item.task) {
|
|
const checkbox = `<input type="checkbox"${item.checked ? ' checked=""' : ''} disabled="" /> `
|
|
itemText = checkbox + itemText
|
|
}
|
|
|
|
// 使用 Lexer 和 Parser 处理剩余的内联标记
|
|
const tokens = marked.Lexer.lexInline(itemText)
|
|
const content = marked.Parser.parseInline(tokens, { renderer: customRenderer })
|
|
|
|
return `<li${styleStr ? ` style="${styleStr}"` : ''}>${content}</li>`
|
|
} catch (error) {
|
|
console.error(`Error rendering list item: ${error}`)
|
|
return `<li>${item.text}</li>`
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
// Convert Markdown to HTML using the custom renderer
|
|
const html = marked.parse(markdown, { renderer: customRenderer }) as string
|
|
|
|
// Apply base styles
|
|
const baseStyles = baseStylesToString(mergedOptions.base)
|
|
return baseStyles ? `<div style="${baseStyles}">${html}</div>` : html
|
|
}
|
|
|
|
export function convertToXiaohongshu(markdown: string): string {
|
|
// 预处理 markdown
|
|
markdown = preprocessMarkdown(markdown)
|
|
|
|
const renderer = new marked.Renderer()
|
|
|
|
// 自定义渲染规则
|
|
renderer.heading = function({ text, depth }: Tokens.Heading) {
|
|
const fontSize = {
|
|
[1]: '20px',
|
|
[2]: '18px',
|
|
[3]: '16px',
|
|
[4]: '15px',
|
|
[5]: '14px',
|
|
[6]: '14px'
|
|
}[depth] || '14px'
|
|
|
|
return `<h${depth} style="margin-top: 25px; margin-bottom: 12px; font-weight: bold; font-size: ${fontSize}; color: #222;">${text}</h${depth}>`
|
|
}
|
|
|
|
renderer.paragraph = function({ text }: Tokens.Paragraph) {
|
|
return `<p style="margin-bottom: 16px; line-height: 1.6; font-size: 15px; color: #222;">${text}</p>`
|
|
}
|
|
|
|
// 使用自定义渲染器转换 Markdown
|
|
return marked.parse(markdown, { renderer }) as string
|
|
}
|
|
|
|
export interface StyleOptions {
|
|
fontSize?: string
|
|
color?: string
|
|
margin?: string
|
|
padding?: string
|
|
border?: string
|
|
borderLeft?: string
|
|
borderBottom?: string
|
|
borderRadius?: string
|
|
background?: string
|
|
fontWeight?: string
|
|
fontStyle?: string
|
|
textDecoration?: string
|
|
display?: string
|
|
lineHeight?: string | number
|
|
textAlign?: string
|
|
paddingLeft?: string
|
|
overflowX?: string
|
|
width?: string
|
|
letterSpacing?: string
|
|
fontFamily?: string
|
|
WebkitBackgroundClip?: string
|
|
WebkitTextFillColor?: string
|
|
}
|
|
|
|
export interface RendererOptions {
|
|
base?: {
|
|
primaryColor?: string
|
|
textAlign?: string
|
|
lineHeight?: string | number
|
|
fontSize?: string
|
|
themeColor?: string
|
|
}
|
|
block?: {
|
|
[key: string]: StyleOptions
|
|
}
|
|
inline?: {
|
|
[key: string]: StyleOptions
|
|
}
|
|
} |