add 代码块主题

This commit is contained in:
tianyaxiang 2025-02-02 09:58:28 +08:00
parent d2f05c7b71
commit ce91021879
12 changed files with 669 additions and 218 deletions

View File

@ -27,12 +27,14 @@
"@tiptap/react": "^2.2.4",
"@tiptap/starter-kit": "^2.2.4",
"@types/marked": "^6.0.0",
"@types/prismjs": "^1.26.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.474.0",
"marked": "^15.0.6",
"next": "14.1.0",
"next-themes": "^0.4.4",
"prismjs": "^1.29.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwind-merge": "^2.6.0",

View File

@ -62,6 +62,9 @@ importers:
'@types/marked':
specifier: ^6.0.0
version: 6.0.0
'@types/prismjs':
specifier: ^1.26.5
version: 1.26.5
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@ -80,6 +83,9 @@ importers:
next-themes:
specifier: ^0.4.4
version: 0.4.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
prismjs:
specifier: ^1.29.0
version: 1.29.0
react:
specifier: ^18.2.0
version: 18.3.1
@ -829,6 +835,9 @@ packages:
'@types/node@20.17.16':
resolution: {integrity: sha512-vOTpLduLkZXePLxHiHsBLp98mHGnl8RptV4YAO3HfKO5UHjDvySGbxKtpYfy8Sx5+WKcgc45qNreJJRVM3L6mw==}
'@types/prismjs@1.26.5':
resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==}
'@types/prop-types@15.7.14':
resolution: {integrity: sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==}
@ -1394,6 +1403,10 @@ packages:
resolution: {integrity: sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==}
engines: {node: ^10 || ^12 || >=14}
prismjs@1.29.0:
resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==}
engines: {node: '>=6'}
prompts@2.4.2:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'}
@ -2441,6 +2454,8 @@ snapshots:
dependencies:
undici-types: 6.19.8
'@types/prismjs@1.26.5': {}
'@types/prop-types@15.7.14': {}
'@types/react-dom@18.3.5(@types/react@18.3.18)':
@ -2955,6 +2970,8 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
prismjs@1.29.0: {}
prompts@2.4.2:
dependencies:
kleur: 3.0.3

View File

@ -1,14 +1,16 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import '@/styles/code-themes.css'
import { ThemeProvider } from '@/components/theme/ThemeProvider'
import { cn } from '@/lib/utils'
import { Inter } from 'next/font/google'
import { Toaster } from '@/components/ui/toaster'
const inter = Inter({ subsets: ['latin'] })
export const metadata = {
title: 'NeuraPress - AI Enhanced Article Editor',
description: 'An intelligent article editor for creating and formatting content',
export const metadata: Metadata = {
title: 'NeuraPress',
description: 'Markdown 转微信公众号内容神器',
icons: {
icon: [
{

View File

@ -0,0 +1,30 @@
'use client'
import { codeThemes, type CodeThemeId } from '@/config/code-themes'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Label } from '@/components/ui/label'
interface CodeThemeSelectorProps {
value: CodeThemeId
onChange: (value: CodeThemeId) => void
}
export function CodeThemeSelector({ value, onChange }: CodeThemeSelectorProps) {
return (
<div className="flex items-center gap-4">
<Label></Label>
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="选择代码主题" />
</SelectTrigger>
<SelectContent>
{codeThemes.map((theme) => (
<SelectItem key={theme.id} value={theme.id}>
{theme.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

View File

@ -6,7 +6,7 @@ import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { convertToWechat } from '@/lib/markdown'
import { type RendererOptions } from '@/lib/markdown'
import { type RendererOptions } from '@/lib/types'
import { useEditorSync } from './hooks/useEditorSync'
import { useAutoSave } from './hooks/useAutoSave'
import { EditorToolbar } from './components/EditorToolbar'
@ -16,6 +16,8 @@ import { type PreviewSize } from './constants'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { WechatStylePicker } from '@/components/template/WechatStylePicker'
import { Copy, Clock, Type, Trash2 } from 'lucide-react'
import { useLocalStorage } from '@/hooks/use-local-storage'
import { codeThemes, type CodeThemeId } from '@/config/code-themes'
// 计算阅读时间假设每分钟阅读300字
const calculateReadingTime = (text: string): string => {
@ -49,6 +51,9 @@ export default function WechatEditor() {
const [wordCount, setWordCount] = useState('0')
const [readingTime, setReadingTime] = useState('1 分钟')
// 添加 codeTheme 状态
const [codeTheme] = useLocalStorage<CodeThemeId>('code-theme', codeThemes[0].id)
// 使用自定义 hooks
const { handleScroll } = useEditorSync(editorRef)
const { handleEditorChange } = useAutoSave(value, setIsDraft)
@ -151,7 +156,8 @@ export default function WechatEditor() {
...(styleOptions.inline?.listitem || {}),
fontSize: styleOptions.base?.fontSize || template?.options?.base?.fontSize || '15px',
}
}
},
codeTheme
}
const html = convertToWechat(value, mergedOptions)
@ -171,7 +177,7 @@ export default function WechatEditor() {
console.error('Template transformation error:', error)
return html
}
}, [value, selectedTemplate, styleOptions])
}, [value, selectedTemplate, styleOptions, codeTheme])
// 处理复制
const handleCopy = useCallback(async () => {
@ -249,21 +255,33 @@ export default function WechatEditor() {
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleSave])
// 延迟更新预览内容
// 更新预览内容
useEffect(() => {
if (!showPreview) return
setIsConverting(true)
const updatePreview = async () => {
if (!value) {
setPreviewContent('')
return
}
setIsConverting(true)
try {
const content = getPreviewContent()
setPreviewContent(content)
} catch (error) {
console.error('Error updating preview:', error)
toast({
variant: "destructive",
title: "预览更新失败",
description: "生成预览内容时发生错误",
})
} finally {
setIsConverting(false)
}
}
updatePreview()
}, [value, selectedTemplate, styleOptions, showPreview, getPreviewContent])
const timeoutId = setTimeout(updatePreview, 100)
return () => clearTimeout(timeoutId)
}, [value, selectedTemplate, styleOptions, codeTheme, getPreviewContent, toast])
// 加载已保存的内容
useEffect(() => {
@ -285,14 +303,13 @@ export default function WechatEditor() {
// 渲染预览内容
const renderPreview = useCallback(() => {
const content = getPreviewContent()
return (
<div
className="preview-content"
dangerouslySetInnerHTML={{ __html: content }}
dangerouslySetInnerHTML={{ __html: previewContent }}
/>
)
}, [getPreviewContent])
}, [previewContent])
// 检测是否为移动设备
const isMobile = useCallback(() => {

View File

@ -2,7 +2,10 @@ import { cn } from '@/lib/utils'
import { PREVIEW_SIZES, type PreviewSize } from '../constants'
import { Loader2, ZoomIn, ZoomOut, Maximize2, Minimize2 } from 'lucide-react'
import { templates } from '@/config/wechat-templates'
import { useState, useRef } from 'react'
import { useState, useRef, useEffect } from 'react'
import { useLocalStorage } from '@/hooks/use-local-storage'
import { codeThemes, type CodeThemeId } from '@/config/code-themes'
import '@/styles/code-themes.css'
interface EditorPreviewProps {
previewRef: React.RefObject<HTMLDivElement>
@ -24,6 +27,7 @@ export function EditorPreview({
const [zoom, setZoom] = useState(100)
const [isFullscreen, setIsFullscreen] = useState(false)
const isScrolling = useRef<boolean>(false)
const [codeTheme] = useLocalStorage<CodeThemeId>('code-theme', codeThemes[0].id)
const handleZoomIn = () => {
setZoom(prev => Math.min(prev + 10, 200))
@ -50,7 +54,8 @@ export function EditorPreview({
"preview-container bg-background transition-all duration-300 ease-in-out flex flex-col",
"h-full sm:w-1/2",
"markdown-body relative",
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles
selectedTemplate && templates.find(t => t.id === selectedTemplate)?.styles,
`code-theme-${codeTheme}`
)}
>
<div className="bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b flex items-center justify-between z-10 sticky top-0 left-0 right-0">

View File

@ -1,4 +1,7 @@
import { Copy, Plus, Save, Smartphone } from 'lucide-react'
'use client'
import { useState } from 'react'
import { Copy, Plus, Save, Smartphone, Settings } from 'lucide-react'
import { cn } from '@/lib/utils'
import { WechatStylePicker } from '../../template/WechatStylePicker'
import { TemplateManager } from '../../template/TemplateManager'
@ -12,6 +15,9 @@ import Link from 'next/link'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { ToastAction } from '@/components/ui/toast'
import { CodeThemeSelector } from '../CodeThemeSelector'
import { useLocalStorage } from '@/hooks/use-local-storage'
import { codeThemes, type CodeThemeId } from '@/config/code-themes'
interface EditorToolbarProps {
value: string
@ -47,6 +53,7 @@ export function EditorToolbar({
styleOptions
}: EditorToolbarProps) {
const { toast } = useToast()
const [codeTheme, setCodeTheme] = useLocalStorage<CodeThemeId>('code-theme', codeThemes[0].id)
const handleCopy = async () => {
try {
@ -117,16 +124,16 @@ export function EditorToolbar({
currentContent={value}
onNew={onNewArticle}
/>
<WechatStylePicker
value={selectedTemplate}
onSelect={onTemplateSelect}
/>
<TemplateManager onTemplateChange={onTemplateChange} />
/>
<CodeThemeSelector value={codeTheme} onChange={setCodeTheme} />
<StyleConfigDialog
value={styleOptions}
onChangeAction={onStyleOptionsChange}
/>
<TemplateManager onTemplateChange={onTemplateChange} />
<button
onClick={onPreviewToggle}
className={cn(

90
src/config/code-themes.ts Normal file
View File

@ -0,0 +1,90 @@
export const codeThemes = [
{
id: 'github',
name: 'GitHub',
description: 'GitHub 风格代码主题',
theme: {
background: '#f6f8fa',
text: '#24292e',
comment: '#6a737d',
keyword: '#d73a49',
string: '#032f62',
number: '#005cc5',
function: '#6f42c1',
class: '#22863a',
variable: '#24292e',
operator: '#d73a49'
}
},
{
id: 'dracula',
name: 'Dracula',
description: '暗黑风格代码主题',
theme: {
background: '#282a36',
text: '#f8f8f2',
comment: '#6272a4',
keyword: '#ff79c6',
string: '#f1fa8c',
number: '#bd93f9',
function: '#50fa7b',
class: '#8be9fd',
variable: '#f8f8f2',
operator: '#ff79c6'
}
},
{
id: 'monokai',
name: 'Monokai',
description: '经典 Sublime Text 主题',
theme: {
background: '#272822',
text: '#f8f8f2',
comment: '#75715e',
keyword: '#f92672',
string: '#e6db74',
number: '#ae81ff',
function: '#a6e22e',
class: '#66d9ef',
variable: '#f8f8f2',
operator: '#f92672'
}
},
{
id: 'solarized-light',
name: 'Solarized Light',
description: '护眼浅色主题',
theme: {
background: '#fdf6e3',
text: '#657b83',
comment: '#93a1a1',
keyword: '#859900',
string: '#2aa198',
number: '#d33682',
function: '#268bd2',
class: '#b58900',
variable: '#657b83',
operator: '#859900'
}
},
{
id: 'nord',
name: 'Nord',
description: '北欧风格主题',
theme: {
background: '#2e3440',
text: '#d8dee9',
comment: '#4c566a',
keyword: '#81a1c1',
string: '#a3be8c',
number: '#b48ead',
function: '#88c0d0',
class: '#8fbcbb',
variable: '#d8dee9',
operator: '#81a1c1'
}
}
] as const
export type CodeTheme = typeof codeThemes[number]
export type CodeThemeId = CodeTheme['id']

View File

@ -3,11 +3,13 @@
import { useState, useEffect } from 'react'
export function useLocalStorage<T>(key: string, initialValue: T) {
// 获取初始值
// State to store our value
// Pass initial state function to useState so logic is only executed once
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === 'undefined') {
return initialValue
}
try {
const item = window.localStorage.getItem(key)
return item ? JSON.parse(item) : initialValue
@ -34,9 +36,11 @@ export function useLocalStorage<T>(key: string, initialValue: T) {
return () => window.removeEventListener('storage', handleStorageChange)
}, [key, initialValue])
// 更新存储的值
// Return a wrapped version of useState's setter function that ...
// ... persists the new value to localStorage.
const setValue = (value: T | ((val: T) => T)) => {
try {
// Allow value to be a function so we have same API as useState
const valueToStore = value instanceof Function ? value(storedValue) : value
setStoredValue(valueToStore)
if (typeof window !== 'undefined') {

View File

@ -1,21 +1,57 @@
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 type { StyleOptions, RendererOptions } from '@/lib/types'
// 将样式对象转换为 CSS 字符串
function cssPropertiesToString(style: StyleOptions = {}): string {
if (!style) return ''
return Object.entries(style)
.filter(([_, value]) => value !== undefined)
.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) {
@ -24,6 +60,12 @@ function baseStylesToString(base: RendererOptions['base'] = {}): string {
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(';')
}
@ -63,19 +105,20 @@ marked.use({
const defaultOptions: RendererOptions = {
base: {
primaryColor: '#333333',
textAlign: 'left',
lineHeight: '1.75',
themeColor: '#1a1a1a',
fontSize: '15px',
themeColor: '#1a1a1a'
lineHeight: '1.75',
textAlign: 'left'
},
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' }
code_pre: {
fontSize: '14px',
overflowX: 'auto',
borderRadius: '8px',
padding: '1em',
lineHeight: '1.5',
margin: '10px 8px'
}
},
inline: {
link: { color: '#576b95' },
@ -84,6 +127,28 @@ const defaultOptions: RendererOptions = {
}
}
// 获取代码主题的样式
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()
@ -91,239 +156,194 @@ export function convertToWechat(markdown: string, options: RendererOptions = def
const mergedOptions = {
base: { ...defaultOptions.base, ...options.base },
block: { ...defaultOptions.block, ...options.block },
inline: { ...defaultOptions.inline, ...options.inline }
inline: { ...defaultOptions.inline, ...options.inline },
codeTheme: options.codeTheme || codeThemes[0].id
}
// 重写 heading 方法
renderer.heading = function({ text, depth }: Tokens.Heading) {
const style = {
...mergedOptions.block?.[`h${depth}`],
color: mergedOptions.base?.themeColor, // 使用主题颜色
textAlign: mergedOptions.base?.textAlign // 添加文本对齐
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}>`
return `<h${depth}${styleStr ? ` style="${styleStr}"` : ''}>${text}</h${depth}>`
}
// 重写 paragraph 方法
renderer.paragraph = function({ text }: Tokens.Paragraph) {
const style = mergedOptions.block?.p || {}
const paragraphStyle = (mergedOptions.block?.p || {}) as StyleOptions
const style: StyleOptions = {
...paragraphStyle,
fontSize: mergedOptions.base?.fontSize,
lineHeight: mergedOptions.base?.lineHeight
}
const styleStr = cssPropertiesToString(style)
const tokens = marked.Lexer.lexInline(text)
const content = marked.Parser.parseInline(tokens, { renderer })
return `<p style="${styleStr}">${content}</p>`
return `<p${styleStr ? ` style="${styleStr}"` : ''}>${text}</p>`
}
// 重写 blockquote 方法
renderer.blockquote = function({ text }: Tokens.Blockquote) {
const style = mergedOptions.block?.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>`
return `<blockquote${styleStr ? ` style="${styleStr}"` : ''}>${text}</blockquote>`
}
// 重写 code 方法
renderer.code = function({ text, lang }: Tokens.Code) {
const style = mergedOptions.block?.code_pre
const codeStyle = (mergedOptions.block?.code_pre || {}) as StyleOptions
const style: StyleOptions = {
...codeStyle,
...getCodeThemeStyles(mergedOptions.codeTheme)
}
const styleStr = cssPropertiesToString(style)
return `<pre${styleStr ? ` style="${styleStr}"` : ''}><code class="language-${lang || ''}">${text}</code></pre>`
// 代码高亮处理
let highlighted = text
if (lang && Prism.languages[lang]) {
try {
const grammar = Prism.languages[lang]
const tokens = Prism.tokenize(text, grammar)
highlighted = tokens.map(token => {
if (typeof token === 'string') {
return token
}
const tokenStyle = getTokenStyles(mergedOptions.codeTheme, token.type)
return `<span style="${tokenStyle}">${token.content}</span>`
}).join('')
} 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 style = mergedOptions.inline?.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 style = mergedOptions.inline?.em
const emStyle = (mergedOptions.inline?.em || {}) as StyleOptions
const style: StyleOptions = {
...emStyle,
fontStyle: 'italic'
}
const styleStr = cssPropertiesToString(style)
return `<em${styleStr ? ` style="${styleStr}"` : ''}>${text}</em>`
}
// 重写 strong 方法
renderer.strong = function({ text }: Tokens.Strong) {
const style = mergedOptions.inline?.strong
const strongStyle = (mergedOptions.inline?.strong || {}) as StyleOptions
const style: StyleOptions = {
...strongStyle,
color: mergedOptions.base?.themeColor,
fontWeight: 'bold'
}
const styleStr = cssPropertiesToString(style)
return `<strong${styleStr ? ` style="${styleStr}"` : ''}>${text}</strong>`
}
// 重写 link 方法
renderer.link = function({ href, title, text }: Tokens.Link) {
const style = mergedOptions.inline?.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 style = mergedOptions.block?.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}"` : ''} />`
return `<img src="${href}"${title ? ` title="${title}"` : ''} alt="${text}"${styleStr ? ` style="${styleStr}"` : ''}>`
}
// 重写 list 方法
renderer.list = function(token: Tokens.List) {
// 重写 list 方法
renderer.list = function(token: Tokens.List) {
const tag = token.ordered ? 'ol' : 'ul'
try {
const style = {
...(mergedOptions.block?.[token.ordered ? 'ol' : 'ul'] || {}),
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}>`
} catch (error) {
console.error(`Error rendering list: ${error}`)
return `<${tag}>${token.items.map(item => renderer.listitem(item)).join('')}</${tag}>`
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) {
try {
const style = {
...(mergedOptions.inline?.listitem || {}),
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>`
} catch (error) {
console.error(`Error rendering list item: ${error}`)
return `<li>${item.text}</li>`
// 重写 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>`
}
// 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 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
listStyle?: string
'@media (max-width: 768px)'?: {
margin?: string
padding?: string
fontSize?: string
}
}
export interface RendererOptions {
base?: {
primaryColor?: string
textAlign?: string
lineHeight?: string | number
fontSize?: string
themeColor?: string
padding?: string
maxWidth?: string
margin?: string
wordBreak?: string
whiteSpace?: string
color?: string
fontFamily?: string
}
block?: {
[key: string]: StyleOptions
}
inline?: {
[key: string]: StyleOptions
}
dark?: {
base?: {
color?: string
}
block?: {
[key: string]: {
color?: string
background?: string
border?: string
borderLeftColor?: string
boxShadow?: string
}
}
}
}

View File

@ -1,10 +1,12 @@
import type { CSSProperties } from 'react'
import type { CodeThemeId } from '@/config/code-themes'
export interface RendererOptions {
base?: {
themeColor?: string
textAlign?: string
fontSize?: string
lineHeight?: string
textAlign?: string
}
block?: {
h1?: StyleOptions
@ -28,6 +30,7 @@ export interface RendererOptions {
link?: StyleOptions
listitem?: StyleOptions
}
codeTheme?: CodeThemeId
}
export interface StyleOptions {

254
src/styles/code-themes.css Normal file
View File

@ -0,0 +1,254 @@
/* GitHub Theme */
.code-theme-github pre {
background-color: #f6f8fa;
color: #24292e;
}
.code-theme-github .token.comment {
color: #6a737d;
}
.code-theme-github .token.keyword {
color: #d73a49;
}
.code-theme-github .token.string {
color: #032f62;
}
.code-theme-github .token.number {
color: #005cc5;
}
.code-theme-github .token.function {
color: #6f42c1;
}
.code-theme-github .token.class-name {
color: #22863a;
}
.code-theme-github .token.operator {
color: #d73a49;
}
/* Dracula Theme */
.code-theme-dracula pre {
background-color: #282a36;
color: #f8f8f2;
}
.code-theme-dracula .token.comment {
color: #6272a4;
}
.code-theme-dracula .token.keyword {
color: #ff79c6;
}
.code-theme-dracula .token.string {
color: #f1fa8c;
}
.code-theme-dracula .token.number {
color: #bd93f9;
}
.code-theme-dracula .token.function {
color: #50fa7b;
}
.code-theme-dracula .token.class-name {
color: #8be9fd;
}
.code-theme-dracula .token.operator {
color: #ff79c6;
}
/* Monokai Theme */
.code-theme-monokai pre {
background-color: #272822;
color: #f8f8f2;
}
.code-theme-monokai .token.comment {
color: #75715e;
}
.code-theme-monokai .token.keyword {
color: #f92672;
}
.code-theme-monokai .token.string {
color: #e6db74;
}
.code-theme-monokai .token.number {
color: #ae81ff;
}
.code-theme-monokai .token.function {
color: #a6e22e;
}
.code-theme-monokai .token.class-name {
color: #66d9ef;
}
.code-theme-monokai .token.operator {
color: #f92672;
}
/* Solarized Light Theme */
.code-theme-solarized-light pre {
background-color: #fdf6e3;
color: #657b83;
}
.code-theme-solarized-light .token.comment {
color: #93a1a1;
}
.code-theme-solarized-light .token.keyword {
color: #859900;
}
.code-theme-solarized-light .token.string {
color: #2aa198;
}
.code-theme-solarized-light .token.number {
color: #d33682;
}
.code-theme-solarized-light .token.function {
color: #268bd2;
}
.code-theme-solarized-light .token.class-name {
color: #b58900;
}
.code-theme-solarized-light .token.operator {
color: #859900;
}
/* Nord Theme */
.code-theme-nord pre {
background-color: #2e3440;
color: #d8dee9;
}
.code-theme-nord .token.comment {
color: #4c566a;
}
.code-theme-nord .token.keyword {
color: #81a1c1;
}
.code-theme-nord .token.string {
color: #a3be8c;
}
.code-theme-nord .token.number {
color: #b48ead;
}
.code-theme-nord .token.function {
color: #88c0d0;
}
.code-theme-nord .token.class-name {
color: #8fbcbb;
}
.code-theme-nord .token.operator {
color: #81a1c1;
}
/* Common styles for all themes */
pre {
padding: 1em;
margin: 0.5em 0;
overflow: auto;
border-radius: 0.3em;
font-family: "Fira Code", Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
font-size: 14px;
line-height: 1.5;
}
code {
font-family: inherit;
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
font-style: italic;
}
.token.function,
.token.class-name {
font-weight: bold;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
/* Line Numbers */
.line-numbers .line-numbers-rows {
border-right: 1px solid #999;
padding-right: 0.5em;
}
/* Code Block Header */
.code-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5em 1em;
font-family: inherit;
font-size: 0.9em;
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.code-header .language {
font-weight: bold;
}
.code-header .copy-button {
background: none;
border: none;
cursor: pointer;
padding: 0.2em 0.5em;
border-radius: 0.2em;
transition: background-color 0.2s;
}
.code-header .copy-button:hover {
background-color: rgba(0, 0, 0, 0.1);
}
/* Dark mode adjustments */
@media (prefers-color-scheme: dark) {
.code-header {
border-bottom-color: rgba(255, 255, 255, 0.1);
}
.code-header .copy-button:hover {
background-color: rgba(255, 255, 255, 0.1);
}
}