重构 markdown 代码
This commit is contained in:
parent
1ac8f26334
commit
7ae14c5599
@ -5,8 +5,7 @@ import { templates } from '@/config/wechat-templates'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useToast } from '@/components/ui/use-toast'
|
import { useToast } from '@/components/ui/use-toast'
|
||||||
import { ToastAction } from '@/components/ui/toast'
|
import { ToastAction } from '@/components/ui/toast'
|
||||||
import { convertToWechat, getCodeThemeStyles } from '@/lib/markdown'
|
import { convertToWechat, getCodeThemeStyles, type RendererOptions } from '@/lib/markdown'
|
||||||
import { type RendererOptions } from '@/lib/types'
|
|
||||||
import { useEditorSync } from './hooks/useEditorSync'
|
import { useEditorSync } from './hooks/useEditorSync'
|
||||||
import { useAutoSave } from './hooks/useAutoSave'
|
import { useAutoSave } from './hooks/useAutoSave'
|
||||||
import { EditorToolbar } from './components/EditorToolbar'
|
import { EditorToolbar } from './components/EditorToolbar'
|
||||||
|
@ -8,7 +8,7 @@ import { TemplateManager } from '../../template/TemplateManager'
|
|||||||
import { StyleConfigDialog } from '../StyleConfigDialog'
|
import { StyleConfigDialog } from '../StyleConfigDialog'
|
||||||
import { ArticleList } from '../ArticleList'
|
import { ArticleList } from '../ArticleList'
|
||||||
import { type Article } from '../constants'
|
import { type Article } from '../constants'
|
||||||
import { type RendererOptions } from '@/lib/types'
|
import { type RendererOptions } from '@/lib/markdown'
|
||||||
import { ThemeToggle } from '@/components/theme/ThemeToggle'
|
import { ThemeToggle } from '@/components/theme/ThemeToggle'
|
||||||
import { Logo } from '@/components/icons/Logo'
|
import { Logo } from '@/components/icons/Logo'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
@ -1,458 +0,0 @@
|
|||||||
import { Marked } from 'marked'
|
|
||||||
import type { CSSProperties } from 'react'
|
|
||||||
import type { Tokens } from 'marked'
|
|
||||||
import { codeThemes, type CodeThemeId } from '@/config/code-themes'
|
|
||||||
import Prism from 'prismjs'
|
|
||||||
import 'prismjs/components/prism-javascript'
|
|
||||||
import 'prismjs/components/prism-typescript'
|
|
||||||
import 'prismjs/components/prism-jsx'
|
|
||||||
import 'prismjs/components/prism-tsx'
|
|
||||||
import 'prismjs/components/prism-css'
|
|
||||||
import 'prismjs/components/prism-scss'
|
|
||||||
import 'prismjs/components/prism-json'
|
|
||||||
import 'prismjs/components/prism-yaml'
|
|
||||||
import 'prismjs/components/prism-markdown'
|
|
||||||
import 'prismjs/components/prism-bash'
|
|
||||||
import 'prismjs/components/prism-python'
|
|
||||||
import 'prismjs/components/prism-java'
|
|
||||||
import 'prismjs/components/prism-go'
|
|
||||||
import 'prismjs/components/prism-rust'
|
|
||||||
import 'prismjs/components/prism-sql'
|
|
||||||
import 'prismjs/components/prism-docker'
|
|
||||||
import 'prismjs/components/prism-nginx'
|
|
||||||
import { StyleOptions, type RendererOptions } from '@/lib/types'
|
|
||||||
|
|
||||||
// 将样式对象转换为 CSS 字符串
|
|
||||||
function cssPropertiesToString(style: StyleOptions = {}): string {
|
|
||||||
if (!style) return ''
|
|
||||||
|
|
||||||
return Object.entries(style)
|
|
||||||
.filter(([_, value]) => value !== undefined && value !== null)
|
|
||||||
.map(([key, value]) => {
|
|
||||||
// 处理媒体查询
|
|
||||||
if (key === '@media (max-width: 768px)') {
|
|
||||||
return '' // 我们不在内联样式中包含媒体查询
|
|
||||||
}
|
|
||||||
|
|
||||||
// 转换驼峰命名为连字符命名
|
|
||||||
const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase()
|
|
||||||
|
|
||||||
// 处理数字值
|
|
||||||
if (typeof value === 'number' && !cssKey.includes('line-height')) {
|
|
||||||
value = `${value}px`
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${cssKey}: ${value}`
|
|
||||||
})
|
|
||||||
.filter(Boolean) // 移除空字符串
|
|
||||||
.join(';')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将基础样式选项转换为 CSS 字符串
|
|
||||||
function baseStylesToString(base: RendererOptions['base'] = {}): string {
|
|
||||||
if (!base) return ''
|
|
||||||
|
|
||||||
const styles: string[] = []
|
|
||||||
|
|
||||||
if (base.lineHeight) {
|
|
||||||
styles.push(`line-height: ${base.lineHeight}`)
|
|
||||||
}
|
|
||||||
if (base.fontSize) {
|
|
||||||
styles.push(`font-size: ${base.fontSize}`)
|
|
||||||
}
|
|
||||||
if (base.textAlign) {
|
|
||||||
styles.push(`text-align: ${base.textAlign}`)
|
|
||||||
}
|
|
||||||
if (base.themeColor) {
|
|
||||||
styles.push(`--theme-color: ${base.themeColor}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
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({
|
|
||||||
gfm: true,
|
|
||||||
breaks: true,
|
|
||||||
async: false,
|
|
||||||
pedantic: false
|
|
||||||
})
|
|
||||||
|
|
||||||
// 重写 marked 配置
|
|
||||||
marked.use({
|
|
||||||
breaks: true,
|
|
||||||
gfm: true,
|
|
||||||
walkTokens(token: Tokens.Generic) {
|
|
||||||
// 确保列表项被正确处理
|
|
||||||
if (token.type === 'list') {
|
|
||||||
(token as Tokens.List).items.forEach(item => {
|
|
||||||
if (item.task) {
|
|
||||||
item.checked = !!item.checked
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const defaultOptions: RendererOptions = {
|
|
||||||
base: {
|
|
||||||
themeColor: '#1a1a1a',
|
|
||||||
fontSize: '15px',
|
|
||||||
lineHeight: '1.75',
|
|
||||||
textAlign: 'left'
|
|
||||||
},
|
|
||||||
block: {
|
|
||||||
code_pre: {
|
|
||||||
fontSize: '14px',
|
|
||||||
overflowX: 'auto',
|
|
||||||
borderRadius: '8px',
|
|
||||||
padding: '1em',
|
|
||||||
lineHeight: '1.5',
|
|
||||||
margin: '10px 8px'
|
|
||||||
},
|
|
||||||
table: {
|
|
||||||
width: '100%',
|
|
||||||
marginBottom: '1em',
|
|
||||||
borderCollapse: 'collapse',
|
|
||||||
fontSize: '14px'
|
|
||||||
},
|
|
||||||
th: {
|
|
||||||
padding: '0.5em 1em',
|
|
||||||
borderBottom: '2px solid var(--theme-color)',
|
|
||||||
textAlign: 'left',
|
|
||||||
fontWeight: 'bold'
|
|
||||||
},
|
|
||||||
td: {
|
|
||||||
padding: '0.5em 1em',
|
|
||||||
borderBottom: '1px solid #eee'
|
|
||||||
},
|
|
||||||
footnotes: {
|
|
||||||
marginTop: '2em',
|
|
||||||
paddingTop: '1em',
|
|
||||||
borderTop: '1px solid #eee',
|
|
||||||
fontSize: '0.9em',
|
|
||||||
color: '#666'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
inline: {
|
|
||||||
link: { color: '#576b95' },
|
|
||||||
codespan: { color: '#333333' },
|
|
||||||
em: { color: '#666666' },
|
|
||||||
del: { color: '#999999', textDecoration: 'line-through' },
|
|
||||||
checkbox: {
|
|
||||||
marginRight: '0.5em',
|
|
||||||
verticalAlign: 'middle'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取代码主题的样式
|
|
||||||
export function getCodeThemeStyles(theme: CodeThemeId): StyleOptions {
|
|
||||||
const themeConfig = codeThemes.find(t => t.id === theme)
|
|
||||||
if (!themeConfig) return {}
|
|
||||||
|
|
||||||
return {
|
|
||||||
background: themeConfig.theme.background,
|
|
||||||
color: themeConfig.theme.text,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取代码token的样式
|
|
||||||
function getTokenStyles(theme: CodeThemeId, tokenType: string): string {
|
|
||||||
const themeConfig = codeThemes.find(t => t.id === theme)
|
|
||||||
if (!themeConfig) return ''
|
|
||||||
|
|
||||||
const tokenColor = themeConfig.theme[tokenType as keyof typeof themeConfig.theme]
|
|
||||||
if (!tokenColor) return ''
|
|
||||||
return `color: ${tokenColor};`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function convertToWechat(markdown: string, options: RendererOptions = defaultOptions): string {
|
|
||||||
const renderer = new marked.Renderer()
|
|
||||||
|
|
||||||
// 合并选项
|
|
||||||
const mergedOptions = {
|
|
||||||
base: { ...defaultOptions.base, ...options.base },
|
|
||||||
block: { ...defaultOptions.block, ...options.block },
|
|
||||||
inline: { ...defaultOptions.inline, ...options.inline },
|
|
||||||
codeTheme: options.codeTheme || codeThemes[0].id
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重写 heading 方法
|
|
||||||
renderer.heading = function({ text, depth }: Tokens.Heading) {
|
|
||||||
const headingKey = `h${depth}` as keyof RendererOptions['block']
|
|
||||||
const headingStyle = (mergedOptions.block?.[headingKey] || {}) as StyleOptions
|
|
||||||
const style: StyleOptions = {
|
|
||||||
...headingStyle,
|
|
||||||
color: mergedOptions.base?.themeColor
|
|
||||||
}
|
|
||||||
const styleStr = cssPropertiesToString(style)
|
|
||||||
const tokens = marked.Lexer.lexInline(text)
|
|
||||||
const content = marked.Parser.parseInline(tokens, { renderer })
|
|
||||||
return `<h${depth}${styleStr ? ` style="${styleStr}"` : ''}>${content}</h${depth}>`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重写 paragraph 方法
|
|
||||||
renderer.paragraph = function({ text, tokens }: Tokens.Paragraph) {
|
|
||||||
const paragraphStyle = (mergedOptions.block?.p || {}) as StyleOptions
|
|
||||||
const style: StyleOptions = {
|
|
||||||
...paragraphStyle,
|
|
||||||
fontSize: mergedOptions.base?.fontSize,
|
|
||||||
lineHeight: mergedOptions.base?.lineHeight
|
|
||||||
}
|
|
||||||
const styleStr = cssPropertiesToString(style)
|
|
||||||
|
|
||||||
// 处理段落中的内联标记
|
|
||||||
let content = text
|
|
||||||
if (tokens) {
|
|
||||||
content = tokens.map(token => {
|
|
||||||
if (token.type === 'text') {
|
|
||||||
const inlineTokens = marked.Lexer.lexInline(token.text)
|
|
||||||
return marked.Parser.parseInline(inlineTokens, { renderer })
|
|
||||||
}
|
|
||||||
return marked.Parser.parseInline([token], { renderer })
|
|
||||||
}).join('')
|
|
||||||
} else {
|
|
||||||
const inlineTokens = marked.Lexer.lexInline(text)
|
|
||||||
content = marked.Parser.parseInline(inlineTokens, { renderer })
|
|
||||||
}
|
|
||||||
|
|
||||||
return `<p${styleStr ? ` style="${styleStr}"` : ''}>${content}</p>`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重写 blockquote 方法
|
|
||||||
renderer.blockquote = function({ text }: Tokens.Blockquote) {
|
|
||||||
const blockquoteStyle = (mergedOptions.block?.blockquote || {}) as StyleOptions
|
|
||||||
const style: StyleOptions = {
|
|
||||||
...blockquoteStyle,
|
|
||||||
borderLeft: `4px solid ${mergedOptions.base?.themeColor || '#1a1a1a'}`
|
|
||||||
}
|
|
||||||
const styleStr = cssPropertiesToString(style)
|
|
||||||
const tokens = marked.Lexer.lexInline(text)
|
|
||||||
const content = marked.Parser.parseInline(tokens, { renderer })
|
|
||||||
|
|
||||||
return `<blockquote${styleStr ? ` style="${styleStr}"` : ''}>${content}</blockquote>`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重写 code 方法
|
|
||||||
renderer.code = function({ text, lang }: Tokens.Code) {
|
|
||||||
const codeStyle = (mergedOptions.block?.code_pre || {}) as StyleOptions
|
|
||||||
const style: StyleOptions = {
|
|
||||||
...codeStyle,
|
|
||||||
...getCodeThemeStyles(mergedOptions.codeTheme)
|
|
||||||
}
|
|
||||||
const styleStr = cssPropertiesToString(style)
|
|
||||||
|
|
||||||
// 代码高亮处理
|
|
||||||
let highlighted = text
|
|
||||||
if (lang && Prism.languages[lang]) {
|
|
||||||
// Helper function to recursively process tokens
|
|
||||||
const processToken = (token: string | Prism.Token, lineNumber?: number): string => {
|
|
||||||
if (typeof token === 'string') {
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokenStyle = getTokenStyles(mergedOptions.codeTheme, token.type)
|
|
||||||
const content = Array.isArray(token.content)
|
|
||||||
? token.content.map(t => processToken(t)).join('')
|
|
||||||
: processToken(token.content)
|
|
||||||
|
|
||||||
return `<span style="${tokenStyle}">${content}</span>`
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const grammar = Prism.languages[lang]
|
|
||||||
const tokens = Prism.tokenize(text, grammar)
|
|
||||||
const lines = text.split('\n')
|
|
||||||
const lineNumbersWidth = lines.length.toString().length * 8 + 20
|
|
||||||
|
|
||||||
highlighted = lines.map((line, index) => {
|
|
||||||
const lineTokens = Prism.tokenize(line, grammar)
|
|
||||||
const processedLine = lineTokens.map(t => processToken(t, index + 1)).join('')
|
|
||||||
return `<div class="code-line"><span class="line-number" style="width:${lineNumbersWidth}px;color:#999;padding-right:1em;text-align:right;display:inline-block;user-select:none;">${index + 1}</span>${processedLine}</div>`
|
|
||||||
}).join('\n')
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error highlighting code: ${error}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return `<pre${styleStr ? ` style="${styleStr}"` : ''}><code class="language-${lang || ''}">${highlighted}</code></pre>`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重写 codespan 方法
|
|
||||||
renderer.codespan = function({ text }: Tokens.Codespan) {
|
|
||||||
const codespanStyle = (mergedOptions.inline?.codespan || {}) as StyleOptions
|
|
||||||
const style: StyleOptions = {
|
|
||||||
...codespanStyle
|
|
||||||
}
|
|
||||||
const styleStr = cssPropertiesToString(style)
|
|
||||||
return `<code${styleStr ? ` style="${styleStr}"` : ''}>${text}</code>`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重写 em 方法
|
|
||||||
renderer.em = function({ text }: Tokens.Em) {
|
|
||||||
const emStyle = (mergedOptions.inline?.em || {}) as StyleOptions
|
|
||||||
const style: StyleOptions = {
|
|
||||||
...emStyle,
|
|
||||||
fontStyle: 'italic'
|
|
||||||
}
|
|
||||||
const styleStr = cssPropertiesToString(style)
|
|
||||||
const tokens = marked.Lexer.lexInline(text)
|
|
||||||
const content = marked.Parser.parseInline(tokens, { renderer })
|
|
||||||
|
|
||||||
return `<em${styleStr ? ` style="${styleStr}"` : ''}>${content}</em>`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重写 strong 方法
|
|
||||||
renderer.strong = function({ text }: Tokens.Strong) {
|
|
||||||
const strongStyle = (mergedOptions.inline?.strong || {}) as StyleOptions
|
|
||||||
const style: StyleOptions = {
|
|
||||||
...strongStyle,
|
|
||||||
color: mergedOptions.base?.themeColor,
|
|
||||||
fontWeight: 'bold'
|
|
||||||
}
|
|
||||||
const styleStr = cssPropertiesToString(style)
|
|
||||||
const tokens = marked.Lexer.lexInline(text)
|
|
||||||
const content = marked.Parser.parseInline(tokens, { renderer })
|
|
||||||
|
|
||||||
return `<strong${styleStr ? ` style="${styleStr}"` : ''}>${content}</strong>`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重写 link 方法
|
|
||||||
renderer.link = function({ href, title, text }: Tokens.Link) {
|
|
||||||
const linkStyle = (mergedOptions.inline?.link || {}) as StyleOptions
|
|
||||||
const style: StyleOptions = {
|
|
||||||
...linkStyle
|
|
||||||
}
|
|
||||||
const styleStr = cssPropertiesToString(style)
|
|
||||||
return `<a href="${href}"${title ? ` title="${title}"` : ''}${styleStr ? ` style="${styleStr}"` : ''}>${text}</a>`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重写 image 方法
|
|
||||||
renderer.image = function({ href, title, text }: Tokens.Image) {
|
|
||||||
const imageStyle = (mergedOptions.block?.image || {}) as StyleOptions
|
|
||||||
const style: StyleOptions = {
|
|
||||||
...imageStyle,
|
|
||||||
maxWidth: '100%',
|
|
||||||
display: 'block',
|
|
||||||
margin: '0.5em auto'
|
|
||||||
}
|
|
||||||
const styleStr = cssPropertiesToString(style)
|
|
||||||
return `<img src="${href}"${title ? ` title="${title}"` : ''} alt="${text}"${styleStr ? ` style="${styleStr}"` : ''}>`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重写 list 方法
|
|
||||||
renderer.list = function(token: Tokens.List) {
|
|
||||||
const tag = token.ordered ? 'ol' : 'ul'
|
|
||||||
const listStyle = (mergedOptions.block?.[tag] || {}) as StyleOptions
|
|
||||||
const style: StyleOptions = {
|
|
||||||
...listStyle,
|
|
||||||
listStyle: token.ordered ? 'decimal' : 'disc',
|
|
||||||
paddingLeft: '2em',
|
|
||||||
marginBottom: '16px'
|
|
||||||
}
|
|
||||||
const styleStr = cssPropertiesToString(style)
|
|
||||||
const startAttr = token.ordered && token.start !== 1 ? ` start="${token.start}"` : ''
|
|
||||||
|
|
||||||
const items = token.items.map(item => {
|
|
||||||
let itemText = item.text
|
|
||||||
if (item.task) {
|
|
||||||
const checkbox = `<input type="checkbox"${item.checked ? ' checked=""' : ''} disabled="" /> `
|
|
||||||
itemText = checkbox + itemText
|
|
||||||
}
|
|
||||||
return renderer.listitem({ ...item, text: itemText })
|
|
||||||
}).join('')
|
|
||||||
|
|
||||||
return `<${tag}${startAttr}${styleStr ? ` style="${styleStr}"` : ''}>${items}</${tag}>`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重写 listitem 方法
|
|
||||||
renderer.listitem = function(item: Tokens.ListItem) {
|
|
||||||
const listitemStyle = (mergedOptions.inline?.listitem || {}) as StyleOptions
|
|
||||||
const style: StyleOptions = {
|
|
||||||
...listitemStyle,
|
|
||||||
marginBottom: '8px',
|
|
||||||
display: 'list-item'
|
|
||||||
}
|
|
||||||
const styleStr = cssPropertiesToString(style)
|
|
||||||
|
|
||||||
// 处理嵌套列表
|
|
||||||
let content = item.text
|
|
||||||
if (item.tokens) {
|
|
||||||
content = item.tokens.map(token => {
|
|
||||||
if (token.type === 'list') {
|
|
||||||
// 递归处理嵌套列表
|
|
||||||
return renderer.list(token as Tokens.List)
|
|
||||||
} else {
|
|
||||||
// 处理其他类型的 token
|
|
||||||
const tokens = marked.Lexer.lexInline(token.raw)
|
|
||||||
return marked.Parser.parseInline(tokens, { renderer })
|
|
||||||
}
|
|
||||||
}).join('')
|
|
||||||
} else {
|
|
||||||
// 如果没有 tokens,则按普通文本处理
|
|
||||||
const tokens = marked.Lexer.lexInline(content)
|
|
||||||
content = marked.Parser.parseInline(tokens, { renderer })
|
|
||||||
}
|
|
||||||
|
|
||||||
return `<li${styleStr ? ` style="${styleStr}"` : ''}>${content}</li>`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加删除线支持
|
|
||||||
renderer.del = function({ text }: Tokens.Del) {
|
|
||||||
const delStyle = (mergedOptions.inline?.del || {}) as StyleOptions
|
|
||||||
const styleStr = cssPropertiesToString(delStyle)
|
|
||||||
return `<del${styleStr ? ` style="${styleStr}"` : ''}>${text}</del>`
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加脚注支持
|
|
||||||
const footnotes = new Map<string, string>()
|
|
||||||
|
|
||||||
marked.use({
|
|
||||||
extensions: [{
|
|
||||||
name: 'footnote',
|
|
||||||
level: 'inline',
|
|
||||||
start(src: string) {
|
|
||||||
const match = src.match(/^\[\^([^\]]+)\]/)
|
|
||||||
return match ? match.index : undefined
|
|
||||||
},
|
|
||||||
tokenizer(src: string) {
|
|
||||||
const match = /^\[\^([^\]]+)\]/.exec(src)
|
|
||||||
if (match) {
|
|
||||||
const token = {
|
|
||||||
type: 'footnote',
|
|
||||||
raw: match[0],
|
|
||||||
text: match[1],
|
|
||||||
tokens: []
|
|
||||||
}
|
|
||||||
return token as unknown as Tokens.Generic
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
},
|
|
||||||
renderer(token: unknown) {
|
|
||||||
const footnoteToken = token as { text: string }
|
|
||||||
const footnoteStyle = (mergedOptions.inline?.footnote || {}) as StyleOptions
|
|
||||||
const styleStr = cssPropertiesToString(footnoteStyle)
|
|
||||||
return `<sup${styleStr ? ` style="${styleStr}"` : ''}><a href="#fn-${footnoteToken.text}">[${footnoteToken.text}]</a></sup>`
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
|
|
||||||
// Convert Markdown to HTML using the custom renderer
|
|
||||||
const html = marked.parse(markdown, { renderer }) as string
|
|
||||||
|
|
||||||
// Apply base styles
|
|
||||||
const baseStyles = baseStylesToString(mergedOptions.base)
|
|
||||||
return baseStyles ? `<section style="${baseStyles}">${html}</section>` : html
|
|
||||||
}
|
|
||||||
|
|
||||||
export type { RendererOptions }
|
|
55
src/lib/markdown/code-highlight.ts
Normal file
55
src/lib/markdown/code-highlight.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import Prism from 'prismjs'
|
||||||
|
import 'prismjs/components/prism-javascript'
|
||||||
|
import 'prismjs/components/prism-typescript'
|
||||||
|
import 'prismjs/components/prism-jsx'
|
||||||
|
import 'prismjs/components/prism-tsx'
|
||||||
|
import 'prismjs/components/prism-css'
|
||||||
|
import 'prismjs/components/prism-scss'
|
||||||
|
import 'prismjs/components/prism-json'
|
||||||
|
import 'prismjs/components/prism-yaml'
|
||||||
|
import 'prismjs/components/prism-markdown'
|
||||||
|
import 'prismjs/components/prism-bash'
|
||||||
|
import 'prismjs/components/prism-python'
|
||||||
|
import 'prismjs/components/prism-java'
|
||||||
|
import 'prismjs/components/prism-go'
|
||||||
|
import 'prismjs/components/prism-rust'
|
||||||
|
import 'prismjs/components/prism-sql'
|
||||||
|
import 'prismjs/components/prism-docker'
|
||||||
|
import 'prismjs/components/prism-nginx'
|
||||||
|
import type { CodeThemeId } from '@/config/code-themes'
|
||||||
|
import { getTokenStyles } from './styles'
|
||||||
|
|
||||||
|
// Helper function to recursively process tokens
|
||||||
|
function processToken(token: string | Prism.Token, codeTheme: CodeThemeId): string {
|
||||||
|
if (typeof token === 'string') {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenStyle = getTokenStyles(codeTheme, token.type)
|
||||||
|
const content = Array.isArray(token.content)
|
||||||
|
? token.content.map(t => processToken(t, codeTheme)).join('')
|
||||||
|
: processToken(token.content, codeTheme)
|
||||||
|
|
||||||
|
return `<span style="${tokenStyle}">${content}</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highlightCode(text: string, lang: string, codeTheme: CodeThemeId): string {
|
||||||
|
if (!lang || !Prism.languages[lang]) {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const grammar = Prism.languages[lang]
|
||||||
|
const lines = text.split('\n')
|
||||||
|
const lineNumbersWidth = lines.length.toString().length * 8 + 20
|
||||||
|
|
||||||
|
return lines.map((line, index) => {
|
||||||
|
const lineTokens = Prism.tokenize(line, grammar)
|
||||||
|
const processedLine = lineTokens.map(t => processToken(t, codeTheme)).join('')
|
||||||
|
return `<div class="code-line"><span class="line-number" style="width:${lineNumbersWidth}px;color:#999;padding-right:1em;text-align:right;display:inline-block;user-select:none;">${index + 1}</span>${processedLine}</div>`
|
||||||
|
}).join('\n')
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error highlighting code: ${error}`)
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
80
src/lib/markdown/index.ts
Normal file
80
src/lib/markdown/index.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import type { RendererOptions } from './types'
|
||||||
|
import { MarkdownParser } from './parser'
|
||||||
|
import { getCodeThemeStyles } from './styles'
|
||||||
|
|
||||||
|
// 预处理函数
|
||||||
|
function preprocessMarkdown(markdown: string): string {
|
||||||
|
return markdown
|
||||||
|
// 处理 ** 语法,但排除已经是 HTML 的部分
|
||||||
|
.replace(/(?<!<[^>]*)\*\*([^*]+)\*\*(?![^<]*>)/g, '<strong>$1</strong>')
|
||||||
|
// 处理无序列表的 - 标记,但排除代码块内的部分
|
||||||
|
.replace(/^(?!\s*```)([ \t]*)-\s+/gm, '$1• ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultOptions: RendererOptions = {
|
||||||
|
base: {
|
||||||
|
themeColor: '#1a1a1a',
|
||||||
|
fontSize: '15px',
|
||||||
|
lineHeight: '1.75',
|
||||||
|
textAlign: 'left'
|
||||||
|
},
|
||||||
|
block: {
|
||||||
|
code_pre: {
|
||||||
|
fontSize: '14px',
|
||||||
|
overflowX: 'auto',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '1em',
|
||||||
|
lineHeight: '1.5',
|
||||||
|
margin: '10px 8px'
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
width: '100%',
|
||||||
|
marginBottom: '1em',
|
||||||
|
borderCollapse: 'collapse',
|
||||||
|
fontSize: '14px'
|
||||||
|
},
|
||||||
|
th: {
|
||||||
|
padding: '0.5em 1em',
|
||||||
|
borderBottom: '2px solid var(--theme-color)',
|
||||||
|
textAlign: 'left',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
},
|
||||||
|
td: {
|
||||||
|
padding: '0.5em 1em',
|
||||||
|
borderBottom: '1px solid #eee'
|
||||||
|
},
|
||||||
|
footnotes: {
|
||||||
|
marginTop: '2em',
|
||||||
|
paddingTop: '1em',
|
||||||
|
borderTop: '1px solid #eee',
|
||||||
|
fontSize: '0.9em',
|
||||||
|
color: '#666'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
inline: {
|
||||||
|
link: { color: '#576b95' },
|
||||||
|
codespan: { color: '#333333' },
|
||||||
|
em: { color: '#666666' },
|
||||||
|
del: { color: '#999999', textDecoration: 'line-through' },
|
||||||
|
checkbox: {
|
||||||
|
marginRight: '0.5em',
|
||||||
|
verticalAlign: 'middle'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertToWechat(markdown: string, options: RendererOptions = defaultOptions): string {
|
||||||
|
const mergedOptions = {
|
||||||
|
base: { ...defaultOptions.base, ...options.base },
|
||||||
|
block: { ...defaultOptions.block, ...options.block },
|
||||||
|
inline: { ...defaultOptions.inline, ...options.inline },
|
||||||
|
codeTheme: options.codeTheme || 'github'
|
||||||
|
}
|
||||||
|
|
||||||
|
const parser = new MarkdownParser(mergedOptions)
|
||||||
|
return parser.parse(markdown)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getCodeThemeStyles }
|
||||||
|
export type { RendererOptions }
|
||||||
|
export * from './types'
|
88
src/lib/markdown/parser.ts
Normal file
88
src/lib/markdown/parser.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { marked } from 'marked'
|
||||||
|
import type { RendererOptions } from './types'
|
||||||
|
import { MarkdownRenderer } from './renderer'
|
||||||
|
import { baseStylesToString } from './styles'
|
||||||
|
|
||||||
|
export class MarkdownParser {
|
||||||
|
private options: RendererOptions
|
||||||
|
private renderer: MarkdownRenderer
|
||||||
|
|
||||||
|
constructor(options: RendererOptions) {
|
||||||
|
this.options = options
|
||||||
|
this.renderer = new MarkdownRenderer(options)
|
||||||
|
this.initializeMarked()
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeMarked() {
|
||||||
|
marked.use({
|
||||||
|
gfm: true,
|
||||||
|
breaks: true,
|
||||||
|
async: false,
|
||||||
|
pedantic: false
|
||||||
|
})
|
||||||
|
|
||||||
|
marked.use({
|
||||||
|
breaks: true,
|
||||||
|
gfm: true,
|
||||||
|
walkTokens(token) {
|
||||||
|
// 确保列表项被正确处理
|
||||||
|
if (token.type === 'list') {
|
||||||
|
(token as any).items.forEach((item: any) => {
|
||||||
|
if (item.task) {
|
||||||
|
item.checked = !!item.checked
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加脚注支持
|
||||||
|
const options = this.options // 在闭包中保存 options 引用
|
||||||
|
marked.use({
|
||||||
|
extensions: [{
|
||||||
|
name: 'footnote',
|
||||||
|
level: 'inline',
|
||||||
|
start(src: string) {
|
||||||
|
const match = src.match(/^\[\^([^\]]+)\]/)
|
||||||
|
return match ? match.index : undefined
|
||||||
|
},
|
||||||
|
tokenizer(src: string) {
|
||||||
|
const match = /^\[\^([^\]]+)\]/.exec(src)
|
||||||
|
if (match) {
|
||||||
|
const token = {
|
||||||
|
type: 'footnote',
|
||||||
|
raw: match[0],
|
||||||
|
text: match[1],
|
||||||
|
tokens: []
|
||||||
|
}
|
||||||
|
return token as any
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
renderer(token: any) {
|
||||||
|
const footnoteStyle = (options?.inline?.footnote || {})
|
||||||
|
const styleStr = Object.entries(footnoteStyle)
|
||||||
|
.map(([key, value]) => `${key}:${value}`)
|
||||||
|
.join(';')
|
||||||
|
return `<sup${styleStr ? ` style="${styleStr}"` : ''}><a href="#fn-${token.text}">[${token.text}]</a></sup>`
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public parse(markdown: string): string {
|
||||||
|
const preprocessed = this.preprocessMarkdown(markdown)
|
||||||
|
const html = marked.parse(preprocessed, { renderer: this.renderer.getRenderer() }) as string
|
||||||
|
const baseStyles = baseStylesToString(this.options.base)
|
||||||
|
return baseStyles ? `<section style="${baseStyles}">${html}</section>` : html
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预处理 markdown 文本
|
||||||
|
private preprocessMarkdown(markdown: string): string {
|
||||||
|
return markdown
|
||||||
|
// 处理 ** 语法,但排除已经是 HTML 的部分
|
||||||
|
.replace(/(?<!<[^>]*)\*\*([^*]+)\*\*(?![^<]*>)/g, '<strong>$1</strong>')
|
||||||
|
// 处理无序列表的 - 标记,但排除代码块内的部分
|
||||||
|
.replace(/^(?!\s*```)([ \t]*)-\s+/gm, '$1• ')
|
||||||
|
}
|
||||||
|
}
|
211
src/lib/markdown/renderer.ts
Normal file
211
src/lib/markdown/renderer.ts
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
import { marked } from 'marked'
|
||||||
|
import type { Tokens } from 'marked'
|
||||||
|
import type { RendererOptions } from './types'
|
||||||
|
import { cssPropertiesToString } from './styles'
|
||||||
|
import { highlightCode } from './code-highlight'
|
||||||
|
|
||||||
|
export class MarkdownRenderer {
|
||||||
|
private renderer: typeof marked.Renderer.prototype
|
||||||
|
private options: RendererOptions
|
||||||
|
|
||||||
|
constructor(options: RendererOptions) {
|
||||||
|
this.options = options
|
||||||
|
this.renderer = new marked.Renderer()
|
||||||
|
this.initializeRenderer()
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeRenderer() {
|
||||||
|
// 重写 heading 方法
|
||||||
|
this.renderer.heading = ({ text, depth }: Tokens.Heading) => {
|
||||||
|
const headingKey = `h${depth}` as keyof RendererOptions['block']
|
||||||
|
const headingStyle = (this.options.block?.[headingKey] || {})
|
||||||
|
const style = {
|
||||||
|
...headingStyle,
|
||||||
|
color: this.options.base?.themeColor
|
||||||
|
}
|
||||||
|
const styleStr = cssPropertiesToString(style)
|
||||||
|
const tokens = marked.Lexer.lexInline(text)
|
||||||
|
const content = marked.Parser.parseInline(tokens, { renderer: this.renderer })
|
||||||
|
return `<h${depth}${styleStr ? ` style="${styleStr}"` : ''}>${content}</h${depth}>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重写 paragraph 方法
|
||||||
|
this.renderer.paragraph = ({ text, tokens }: Tokens.Paragraph) => {
|
||||||
|
const paragraphStyle = (this.options.block?.p || {})
|
||||||
|
const style = {
|
||||||
|
...paragraphStyle,
|
||||||
|
fontSize: this.options.base?.fontSize,
|
||||||
|
lineHeight: this.options.base?.lineHeight
|
||||||
|
}
|
||||||
|
const styleStr = cssPropertiesToString(style)
|
||||||
|
|
||||||
|
// 处理段落中的内联标记
|
||||||
|
let content = text
|
||||||
|
if (tokens) {
|
||||||
|
content = tokens.map(token => {
|
||||||
|
if (token.type === 'text') {
|
||||||
|
const inlineTokens = marked.Lexer.lexInline(token.text)
|
||||||
|
return marked.Parser.parseInline(inlineTokens, { renderer: this.renderer })
|
||||||
|
}
|
||||||
|
return marked.Parser.parseInline([token], { renderer: this.renderer })
|
||||||
|
}).join('')
|
||||||
|
} else {
|
||||||
|
const inlineTokens = marked.Lexer.lexInline(text)
|
||||||
|
content = marked.Parser.parseInline(inlineTokens, { renderer: this.renderer })
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<p${styleStr ? ` style="${styleStr}"` : ''}>${content}</p>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重写 blockquote 方法
|
||||||
|
this.renderer.blockquote = ({ text }: Tokens.Blockquote) => {
|
||||||
|
const blockquoteStyle = (this.options.block?.blockquote || {})
|
||||||
|
const style = {
|
||||||
|
...blockquoteStyle,
|
||||||
|
borderLeft: `4px solid ${this.options.base?.themeColor || '#1a1a1a'}`
|
||||||
|
}
|
||||||
|
const styleStr = cssPropertiesToString(style)
|
||||||
|
const tokens = marked.Lexer.lexInline(text)
|
||||||
|
const content = marked.Parser.parseInline(tokens, { renderer: this.renderer })
|
||||||
|
|
||||||
|
return `<blockquote${styleStr ? ` style="${styleStr}"` : ''}>${content}</blockquote>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重写 code 方法
|
||||||
|
this.renderer.code = ({ text, lang }: Tokens.Code) => {
|
||||||
|
const codeStyle = (this.options.block?.code_pre || {})
|
||||||
|
const style = {
|
||||||
|
...codeStyle
|
||||||
|
}
|
||||||
|
const styleStr = cssPropertiesToString(style)
|
||||||
|
|
||||||
|
const highlighted = highlightCode(text, lang || '', this.options.codeTheme || 'github')
|
||||||
|
|
||||||
|
return `<pre${styleStr ? ` style="${styleStr}"` : ''}><code class="language-${lang || ''}">${highlighted}</code></pre>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重写 codespan 方法
|
||||||
|
this.renderer.codespan = ({ text }: Tokens.Codespan) => {
|
||||||
|
const codespanStyle = (this.options.inline?.codespan || {})
|
||||||
|
const styleStr = cssPropertiesToString(codespanStyle)
|
||||||
|
return `<code${styleStr ? ` style="${styleStr}"` : ''}>${text}</code>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重写 em 方法
|
||||||
|
this.renderer.em = ({ text }: Tokens.Em) => {
|
||||||
|
const emStyle = (this.options.inline?.em || {})
|
||||||
|
const style = {
|
||||||
|
...emStyle,
|
||||||
|
fontStyle: 'italic'
|
||||||
|
}
|
||||||
|
const styleStr = cssPropertiesToString(style)
|
||||||
|
const tokens = marked.Lexer.lexInline(text)
|
||||||
|
const content = marked.Parser.parseInline(tokens, { renderer: this.renderer })
|
||||||
|
|
||||||
|
return `<em${styleStr ? ` style="${styleStr}"` : ''}>${content}</em>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重写 strong 方法
|
||||||
|
this.renderer.strong = ({ text }: Tokens.Strong) => {
|
||||||
|
const strongStyle = (this.options.inline?.strong || {})
|
||||||
|
const style = {
|
||||||
|
...strongStyle,
|
||||||
|
color: this.options.base?.themeColor,
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}
|
||||||
|
const styleStr = cssPropertiesToString(style)
|
||||||
|
const tokens = marked.Lexer.lexInline(text)
|
||||||
|
const content = marked.Parser.parseInline(tokens, { renderer: this.renderer })
|
||||||
|
|
||||||
|
return `<strong${styleStr ? ` style="${styleStr}"` : ''}>${content}</strong>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重写 link 方法
|
||||||
|
this.renderer.link = ({ href, title, text }: Tokens.Link) => {
|
||||||
|
const linkStyle = (this.options.inline?.link || {})
|
||||||
|
const styleStr = cssPropertiesToString(linkStyle)
|
||||||
|
return `<a href="${href}"${title ? ` title="${title}"` : ''}${styleStr ? ` style="${styleStr}"` : ''}>${text}</a>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重写 image 方法
|
||||||
|
this.renderer.image = ({ href, title, text }: Tokens.Image) => {
|
||||||
|
const imageStyle = (this.options.block?.image || {})
|
||||||
|
const style = {
|
||||||
|
...imageStyle,
|
||||||
|
maxWidth: '100%',
|
||||||
|
display: 'block',
|
||||||
|
margin: '0.5em auto'
|
||||||
|
}
|
||||||
|
const styleStr = cssPropertiesToString(style)
|
||||||
|
return `<img src="${href}"${title ? ` title="${title}"` : ''} alt="${text}"${styleStr ? ` style="${styleStr}"` : ''}>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重写 list 方法
|
||||||
|
this.renderer.list = (token: Tokens.List) => {
|
||||||
|
const tag = token.ordered ? 'ol' : 'ul'
|
||||||
|
const listStyle = (this.options.block?.[tag] || {})
|
||||||
|
const style = {
|
||||||
|
...listStyle,
|
||||||
|
listStyle: token.ordered ? 'decimal' : 'disc',
|
||||||
|
paddingLeft: '2em',
|
||||||
|
marginBottom: '16px'
|
||||||
|
}
|
||||||
|
const styleStr = cssPropertiesToString(style)
|
||||||
|
const startAttr = token.ordered && token.start !== 1 ? ` start="${token.start}"` : ''
|
||||||
|
|
||||||
|
const items = token.items.map(item => {
|
||||||
|
let itemText = item.text
|
||||||
|
if (item.task) {
|
||||||
|
const checkbox = `<input type="checkbox"${item.checked ? ' checked=""' : ''} disabled="" /> `
|
||||||
|
itemText = checkbox + itemText
|
||||||
|
}
|
||||||
|
return this.renderer.listitem({ ...item, text: itemText })
|
||||||
|
}).join('')
|
||||||
|
|
||||||
|
return `<${tag}${startAttr}${styleStr ? ` style="${styleStr}"` : ''}>${items}</${tag}>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重写 listitem 方法
|
||||||
|
this.renderer.listitem = (item: Tokens.ListItem) => {
|
||||||
|
const listitemStyle = (this.options.inline?.listitem || {})
|
||||||
|
const style = {
|
||||||
|
...listitemStyle,
|
||||||
|
marginBottom: '8px',
|
||||||
|
display: 'list-item'
|
||||||
|
}
|
||||||
|
const styleStr = cssPropertiesToString(style)
|
||||||
|
|
||||||
|
// 处理嵌套列表
|
||||||
|
let content = item.text
|
||||||
|
if (item.tokens) {
|
||||||
|
content = item.tokens.map(token => {
|
||||||
|
if (token.type === 'list') {
|
||||||
|
// 递归处理嵌套列表
|
||||||
|
return this.renderer.list(token as Tokens.List)
|
||||||
|
} else {
|
||||||
|
// 处理其他类型的 token
|
||||||
|
const tokens = marked.Lexer.lexInline(token.raw)
|
||||||
|
return marked.Parser.parseInline(tokens, { renderer: this.renderer })
|
||||||
|
}
|
||||||
|
}).join('')
|
||||||
|
} else {
|
||||||
|
// 如果没有 tokens,则按普通文本处理
|
||||||
|
const tokens = marked.Lexer.lexInline(content)
|
||||||
|
content = marked.Parser.parseInline(tokens, { renderer: this.renderer })
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<li${styleStr ? ` style="${styleStr}"` : ''}>${content}</li>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加删除线支持
|
||||||
|
this.renderer.del = ({ text }: Tokens.Del) => {
|
||||||
|
const delStyle = (this.options.inline?.del || {})
|
||||||
|
const styleStr = cssPropertiesToString(delStyle)
|
||||||
|
return `<del${styleStr ? ` style="${styleStr}"` : ''}>${text}</del>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public getRenderer(): typeof marked.Renderer.prototype {
|
||||||
|
return this.renderer
|
||||||
|
}
|
||||||
|
}
|
71
src/lib/markdown/styles.ts
Normal file
71
src/lib/markdown/styles.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import type { StyleOptions, RendererOptions } from './types'
|
||||||
|
import { codeThemes, type CodeThemeId } from '@/config/code-themes'
|
||||||
|
|
||||||
|
// 将样式对象转换为 CSS 字符串
|
||||||
|
export function cssPropertiesToString(style: StyleOptions = {}): string {
|
||||||
|
if (!style) return ''
|
||||||
|
|
||||||
|
return Object.entries(style)
|
||||||
|
.filter(([_, value]) => value !== undefined && value !== null)
|
||||||
|
.map(([key, value]) => {
|
||||||
|
// 处理媒体查询
|
||||||
|
if (key === '@media (max-width: 768px)') {
|
||||||
|
return '' // 我们不在内联样式中包含媒体查询
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换驼峰命名为连字符命名
|
||||||
|
const cssKey = key.replace(/([A-Z])/g, '-$1').toLowerCase()
|
||||||
|
|
||||||
|
// 处理数字值
|
||||||
|
if (typeof value === 'number' && !cssKey.includes('line-height')) {
|
||||||
|
value = `${value}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${cssKey}: ${value}`
|
||||||
|
})
|
||||||
|
.filter(Boolean) // 移除空字符串
|
||||||
|
.join(';')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将基础样式选项转换为 CSS 字符串
|
||||||
|
export function baseStylesToString(base: RendererOptions['base'] = {}): string {
|
||||||
|
if (!base) return ''
|
||||||
|
|
||||||
|
const styles: string[] = []
|
||||||
|
|
||||||
|
if (base.lineHeight) {
|
||||||
|
styles.push(`line-height: ${base.lineHeight}`)
|
||||||
|
}
|
||||||
|
if (base.fontSize) {
|
||||||
|
styles.push(`font-size: ${base.fontSize}`)
|
||||||
|
}
|
||||||
|
if (base.textAlign) {
|
||||||
|
styles.push(`text-align: ${base.textAlign}`)
|
||||||
|
}
|
||||||
|
if (base.themeColor) {
|
||||||
|
styles.push(`--theme-color: ${base.themeColor}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return styles.join(';')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取代码主题的样式
|
||||||
|
export function getCodeThemeStyles(theme: CodeThemeId): StyleOptions {
|
||||||
|
const themeConfig = codeThemes.find(t => t.id === theme)
|
||||||
|
if (!themeConfig) return {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
background: themeConfig.theme.background,
|
||||||
|
color: themeConfig.theme.text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取代码token的样式
|
||||||
|
export function getTokenStyles(theme: CodeThemeId, tokenType: string): string {
|
||||||
|
const themeConfig = codeThemes.find(t => t.id === theme)
|
||||||
|
if (!themeConfig) return ''
|
||||||
|
|
||||||
|
const tokenColor = themeConfig.theme[tokenType as keyof typeof themeConfig.theme]
|
||||||
|
if (!tokenColor) return ''
|
||||||
|
return `color: ${tokenColor};`
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user