From 7ae14c5599c406d7b0ff93a8b470c2f0e233ee03 Mon Sep 17 00:00:00 2001 From: tianyaxiang Date: Sun, 2 Feb 2025 15:59:10 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=20markdown=20=E4=BB=A3?= =?UTF-8?q?=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/editor/WechatEditor.tsx | 3 +- .../editor/components/EditorToolbar.tsx | 2 +- src/lib/markdown.ts | 458 ------------------ src/lib/markdown/code-highlight.ts | 55 +++ src/lib/markdown/index.ts | 80 +++ src/lib/markdown/parser.ts | 88 ++++ src/lib/markdown/renderer.ts | 211 ++++++++ src/lib/markdown/styles.ts | 71 +++ src/lib/{ => markdown}/types.ts | 0 9 files changed, 507 insertions(+), 461 deletions(-) delete mode 100644 src/lib/markdown.ts create mode 100644 src/lib/markdown/code-highlight.ts create mode 100644 src/lib/markdown/index.ts create mode 100644 src/lib/markdown/parser.ts create mode 100644 src/lib/markdown/renderer.ts create mode 100644 src/lib/markdown/styles.ts rename src/lib/{ => markdown}/types.ts (100%) diff --git a/src/components/editor/WechatEditor.tsx b/src/components/editor/WechatEditor.tsx index 06e1582..9581978 100644 --- a/src/components/editor/WechatEditor.tsx +++ b/src/components/editor/WechatEditor.tsx @@ -5,8 +5,7 @@ import { templates } from '@/config/wechat-templates' import { cn } from '@/lib/utils' import { useToast } from '@/components/ui/use-toast' import { ToastAction } from '@/components/ui/toast' -import { convertToWechat, getCodeThemeStyles } from '@/lib/markdown' -import { type RendererOptions } from '@/lib/types' +import { convertToWechat, getCodeThemeStyles, type RendererOptions } from '@/lib/markdown' import { useEditorSync } from './hooks/useEditorSync' import { useAutoSave } from './hooks/useAutoSave' import { EditorToolbar } from './components/EditorToolbar' diff --git a/src/components/editor/components/EditorToolbar.tsx b/src/components/editor/components/EditorToolbar.tsx index e245fe7..05aacb4 100644 --- a/src/components/editor/components/EditorToolbar.tsx +++ b/src/components/editor/components/EditorToolbar.tsx @@ -8,7 +8,7 @@ import { TemplateManager } from '../../template/TemplateManager' import { StyleConfigDialog } from '../StyleConfigDialog' import { ArticleList } from '../ArticleList' import { type Article } from '../constants' -import { type RendererOptions } from '@/lib/types' +import { type RendererOptions } from '@/lib/markdown' import { ThemeToggle } from '@/components/theme/ThemeToggle' import { Logo } from '@/components/icons/Logo' import Link from 'next/link' diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts deleted file mode 100644 index dca5e7c..0000000 --- a/src/lib/markdown.ts +++ /dev/null @@ -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, '$1') - // 处理无序列表的 - 标记,但排除代码块内的部分 - .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 `${content}` - } - - // 重写 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 `${content}

` - } - - // 重写 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 `${content}` - } - - // 重写 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 `${content}` - } - - 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 `
${index + 1}${processedLine}
` - }).join('\n') - } catch (error) { - console.error(`Error highlighting code: ${error}`) - } - } - - return `${highlighted}` - } - - // 重写 codespan 方法 - renderer.codespan = function({ text }: Tokens.Codespan) { - const codespanStyle = (mergedOptions.inline?.codespan || {}) as StyleOptions - const style: StyleOptions = { - ...codespanStyle - } - const styleStr = cssPropertiesToString(style) - return `${text}` - } - - // 重写 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 `${content}` - } - - // 重写 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 `${content}` - } - - // 重写 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 `${text}` - } - - // 重写 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 `${text}` - } - - // 重写 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 = ` ` - itemText = checkbox + itemText - } - return renderer.listitem({ ...item, text: itemText }) - }).join('') - - return `<${tag}${startAttr}${styleStr ? ` style="${styleStr}"` : ''}>${items}` - } - - // 重写 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 `${content}` - } - - // 添加删除线支持 - renderer.del = function({ text }: Tokens.Del) { - const delStyle = (mergedOptions.inline?.del || {}) as StyleOptions - const styleStr = cssPropertiesToString(delStyle) - return `${text}` - } - - // 添加脚注支持 - const footnotes = new Map() - - 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 `[${footnoteToken.text}]` - } - }] - }) - - // 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 ? `
${html}
` : html -} - -export type { RendererOptions } \ No newline at end of file diff --git a/src/lib/markdown/code-highlight.ts b/src/lib/markdown/code-highlight.ts new file mode 100644 index 0000000..a447998 --- /dev/null +++ b/src/lib/markdown/code-highlight.ts @@ -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 `${content}` +} + +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 `
${index + 1}${processedLine}
` + }).join('\n') + } catch (error) { + console.error(`Error highlighting code: ${error}`) + return text + } +} \ No newline at end of file diff --git a/src/lib/markdown/index.ts b/src/lib/markdown/index.ts new file mode 100644 index 0000000..5fe2a8f --- /dev/null +++ b/src/lib/markdown/index.ts @@ -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, '$1') + // 处理无序列表的 - 标记,但排除代码块内的部分 + .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' \ No newline at end of file diff --git a/src/lib/markdown/parser.ts b/src/lib/markdown/parser.ts new file mode 100644 index 0000000..81ad418 --- /dev/null +++ b/src/lib/markdown/parser.ts @@ -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 `[${token.text}]` + } + }] + }) + } + + 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 ? `
${html}
` : html + } + + // 预处理 markdown 文本 + private preprocessMarkdown(markdown: string): string { + return markdown + // 处理 ** 语法,但排除已经是 HTML 的部分 + .replace(/(?]*)\*\*([^*]+)\*\*(?![^<]*>)/g, '$1') + // 处理无序列表的 - 标记,但排除代码块内的部分 + .replace(/^(?!\s*```)([ \t]*)-\s+/gm, '$1• ') + } +} \ No newline at end of file diff --git a/src/lib/markdown/renderer.ts b/src/lib/markdown/renderer.ts new file mode 100644 index 0000000..dc61cbf --- /dev/null +++ b/src/lib/markdown/renderer.ts @@ -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 `${content}` + } + + // 重写 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 `${content}

` + } + + // 重写 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 `${content}` + } + + // 重写 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 `${highlighted}` + } + + // 重写 codespan 方法 + this.renderer.codespan = ({ text }: Tokens.Codespan) => { + const codespanStyle = (this.options.inline?.codespan || {}) + const styleStr = cssPropertiesToString(codespanStyle) + return `${text}` + } + + // 重写 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 `${content}` + } + + // 重写 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 `${content}` + } + + // 重写 link 方法 + this.renderer.link = ({ href, title, text }: Tokens.Link) => { + const linkStyle = (this.options.inline?.link || {}) + const styleStr = cssPropertiesToString(linkStyle) + return `${text}` + } + + // 重写 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 `${text}` + } + + // 重写 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 = ` ` + itemText = checkbox + itemText + } + return this.renderer.listitem({ ...item, text: itemText }) + }).join('') + + return `<${tag}${startAttr}${styleStr ? ` style="${styleStr}"` : ''}>${items}` + } + + // 重写 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 `${content}` + } + + // 添加删除线支持 + this.renderer.del = ({ text }: Tokens.Del) => { + const delStyle = (this.options.inline?.del || {}) + const styleStr = cssPropertiesToString(delStyle) + return `${text}` + } + } + + public getRenderer(): typeof marked.Renderer.prototype { + return this.renderer + } +} \ No newline at end of file diff --git a/src/lib/markdown/styles.ts b/src/lib/markdown/styles.ts new file mode 100644 index 0000000..9b93b7a --- /dev/null +++ b/src/lib/markdown/styles.ts @@ -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};` +} \ No newline at end of file diff --git a/src/lib/types.ts b/src/lib/markdown/types.ts similarity index 100% rename from src/lib/types.ts rename to src/lib/markdown/types.ts