@consilioweb/payload-support 0.8.2 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/RichTextEditor/index.cjs +233 -0
- package/dist/components/RichTextEditor/index.js +232 -0
- package/dist/components/TicketConversation/components/CodeBlock.cjs +24 -7
- package/dist/components/TicketConversation/components/CodeBlock.js +23 -9
- package/dist/index.cjs +19 -1
- package/dist/index.js +19 -1
- package/dist/views/BillingView/client.cjs +260 -103
- package/dist/views/BillingView/client.js +259 -103
- package/dist/views/ChatView/client.cjs +184 -137
- package/dist/views/ChatView/client.js +180 -137
- package/dist/views/CrmView/client.cjs +270 -122
- package/dist/views/CrmView/client.js +266 -122
- package/dist/views/EmailTrackingView/client.cjs +80 -69
- package/dist/views/EmailTrackingView/client.js +80 -70
- package/dist/views/ImportConversationView/client.cjs +127 -94
- package/dist/views/ImportConversationView/client.js +123 -94
- package/dist/views/LogsView/client.cjs +56 -58
- package/dist/views/LogsView/client.js +52 -58
- package/dist/views/NewTicketView/client.cjs +39 -55
- package/dist/views/NewTicketView/client.js +38 -55
- package/dist/views/PendingEmailsView/client.cjs +399 -102
- package/dist/views/PendingEmailsView/client.js +396 -103
- package/dist/views/SupportDashboardView/client.cjs +276 -137
- package/dist/views/SupportDashboardView/client.js +275 -137
- package/dist/views/TicketDetailView/client.cjs +487 -204
- package/dist/views/TicketDetailView/client.js +486 -204
- package/dist/views/TicketInboxView/client.cjs +62 -65
- package/dist/views/TicketInboxView/client.js +62 -66
- package/dist/views/TicketingSettingsView/client.cjs +10 -8
- package/dist/views/TicketingSettingsView/client.js +10 -8
- package/dist/views/TimeDashboardView/client.cjs +70 -59
- package/dist/views/TimeDashboardView/client.js +69 -59
- package/package.json +6 -2
- package/src/components/RichTextEditor/index.tsx +261 -0
- package/src/components/TicketConversation/components/CodeBlock.tsx +53 -14
- package/src/plugin.ts +2 -0
- package/src/utils/emailTemplate.ts +37 -0
- package/src/views/BillingView/client.tsx +362 -69
- package/src/views/ChatView/client.tsx +225 -140
- package/src/views/CrmView/client.tsx +447 -189
- package/src/views/EmailTrackingView/client.tsx +111 -71
- package/src/views/ImportConversationView/client.tsx +255 -70
- package/src/views/LogsView/client.tsx +85 -50
- package/src/views/NewTicketView/client.tsx +37 -53
- package/src/views/PendingEmailsView/client.tsx +512 -92
- package/src/views/SupportDashboardView/client.tsx +294 -134
- package/src/views/TicketDetailView/client.tsx +486 -213
- package/src/views/TicketInboxView/client.tsx +52 -61
- package/src/views/TicketingSettingsView/client.tsx +10 -9
- package/src/views/TimeDashboardView/client.tsx +184 -69
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React, { useRef, useCallback, useState, useImperativeHandle, forwardRef, useEffect } from 'react'
|
|
4
|
+
|
|
5
|
+
export interface RichTextEditorHandle {
|
|
6
|
+
clear: () => void
|
|
7
|
+
setContent: (html: string) => void
|
|
8
|
+
getHtml: () => string
|
|
9
|
+
getPlainText: () => string
|
|
10
|
+
focus: () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
initialValue?: string
|
|
15
|
+
onChange: (html: string, plainText: string) => void
|
|
16
|
+
placeholder?: string
|
|
17
|
+
minHeight?: number
|
|
18
|
+
onFileUpload?: (file: File) => Promise<string | null>
|
|
19
|
+
borderColor?: string
|
|
20
|
+
focusBorderColor?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const RichTextEditor = forwardRef<RichTextEditorHandle, Props>(function RichTextEditor(
|
|
24
|
+
{
|
|
25
|
+
initialValue = '',
|
|
26
|
+
onChange,
|
|
27
|
+
placeholder = 'Écrivez votre message...',
|
|
28
|
+
minHeight = 150,
|
|
29
|
+
onFileUpload,
|
|
30
|
+
borderColor = '#000',
|
|
31
|
+
focusBorderColor = '#00E5FF',
|
|
32
|
+
},
|
|
33
|
+
ref,
|
|
34
|
+
) {
|
|
35
|
+
const editorRef = useRef<HTMLDivElement>(null)
|
|
36
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
37
|
+
const [focused, setFocused] = useState(false)
|
|
38
|
+
const [isEmpty, setIsEmpty] = useState(!initialValue)
|
|
39
|
+
|
|
40
|
+
// Set initial content via ref (avoids React reconciliation issues with contentEditable)
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (editorRef.current && initialValue) {
|
|
43
|
+
editorRef.current.innerHTML = initialValue
|
|
44
|
+
const text = editorRef.current.innerText?.trim() || ''
|
|
45
|
+
setIsEmpty(!text && !editorRef.current.querySelector('img'))
|
|
46
|
+
}
|
|
47
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
48
|
+
}, [])
|
|
49
|
+
|
|
50
|
+
const emitChange = useCallback(() => {
|
|
51
|
+
if (!editorRef.current) return
|
|
52
|
+
const html = editorRef.current.innerHTML
|
|
53
|
+
const text = editorRef.current.innerText?.trim() || ''
|
|
54
|
+
const empty = !text && !editorRef.current.querySelector('img')
|
|
55
|
+
setIsEmpty(empty)
|
|
56
|
+
onChange(empty ? '' : html, text)
|
|
57
|
+
}, [onChange])
|
|
58
|
+
|
|
59
|
+
const exec = useCallback((command: string, value?: string) => {
|
|
60
|
+
document.execCommand(command, false, value)
|
|
61
|
+
editorRef.current?.focus()
|
|
62
|
+
setTimeout(emitChange, 0)
|
|
63
|
+
}, [emitChange])
|
|
64
|
+
|
|
65
|
+
useImperativeHandle(ref, () => ({
|
|
66
|
+
clear: () => {
|
|
67
|
+
if (editorRef.current) {
|
|
68
|
+
editorRef.current.innerHTML = ''
|
|
69
|
+
setIsEmpty(true)
|
|
70
|
+
onChange('', '')
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
setContent: (html: string) => {
|
|
74
|
+
if (editorRef.current) {
|
|
75
|
+
editorRef.current.innerHTML = html
|
|
76
|
+
const text = editorRef.current.innerText?.trim() || ''
|
|
77
|
+
setIsEmpty(!text && !editorRef.current.querySelector('img'))
|
|
78
|
+
onChange(html, text)
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
getHtml: () => editorRef.current?.innerHTML || '',
|
|
82
|
+
getPlainText: () => editorRef.current?.innerText?.trim() || '',
|
|
83
|
+
focus: () => editorRef.current?.focus(),
|
|
84
|
+
}))
|
|
85
|
+
|
|
86
|
+
const handleInsertLink = useCallback(() => {
|
|
87
|
+
const sel = window.getSelection()
|
|
88
|
+
if (!sel || sel.isCollapsed) {
|
|
89
|
+
const url = prompt('URL du lien :')
|
|
90
|
+
if (!url) return
|
|
91
|
+
const text = prompt('Texte du lien :', url) || url
|
|
92
|
+
const safeUrl = url.replace(/"/g, '"')
|
|
93
|
+
const safeText = text.replace(/</g, '<').replace(/>/g, '>')
|
|
94
|
+
document.execCommand('insertHTML', false, `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${safeText}</a>`)
|
|
95
|
+
editorRef.current?.focus()
|
|
96
|
+
setTimeout(emitChange, 0)
|
|
97
|
+
} else {
|
|
98
|
+
const url = prompt('URL du lien :')
|
|
99
|
+
if (url) exec('createLink', url)
|
|
100
|
+
}
|
|
101
|
+
}, [exec, emitChange])
|
|
102
|
+
|
|
103
|
+
const handleImageClick = useCallback(() => {
|
|
104
|
+
if (onFileUpload) {
|
|
105
|
+
fileInputRef.current?.click()
|
|
106
|
+
} else {
|
|
107
|
+
const url = prompt('URL de l\'image :')
|
|
108
|
+
if (url) {
|
|
109
|
+
const safeUrl = url.replace(/"/g, '"')
|
|
110
|
+
document.execCommand('insertHTML', false, `<img src="${safeUrl}" alt="Image" style="max-width:100%;height:auto;border-radius:8px;margin:8px 0;" />`)
|
|
111
|
+
editorRef.current?.focus()
|
|
112
|
+
setTimeout(emitChange, 0)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}, [onFileUpload, emitChange])
|
|
116
|
+
|
|
117
|
+
const handleFileChange = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
118
|
+
if (!e.target.files || !onFileUpload) return
|
|
119
|
+
for (const file of Array.from(e.target.files)) {
|
|
120
|
+
if (!file.type.startsWith('image/')) continue
|
|
121
|
+
const url = await onFileUpload(file)
|
|
122
|
+
if (url) {
|
|
123
|
+
const safeName = file.name.replace(/"/g, '"')
|
|
124
|
+
document.execCommand('insertHTML', false, `<img src="${url}" alt="${safeName}" style="max-width:100%;height:auto;border-radius:8px;margin:8px 0;" />`)
|
|
125
|
+
editorRef.current?.focus()
|
|
126
|
+
setTimeout(emitChange, 0)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (fileInputRef.current) fileInputRef.current.value = ''
|
|
130
|
+
}, [onFileUpload, emitChange])
|
|
131
|
+
|
|
132
|
+
const handlePaste = useCallback(async (e: React.ClipboardEvent) => {
|
|
133
|
+
if (!onFileUpload) return
|
|
134
|
+
const items = e.clipboardData?.items
|
|
135
|
+
if (!items) return
|
|
136
|
+
for (const item of Array.from(items)) {
|
|
137
|
+
if (item.type.startsWith('image/')) {
|
|
138
|
+
e.preventDefault()
|
|
139
|
+
const file = item.getAsFile()
|
|
140
|
+
if (file) {
|
|
141
|
+
const url = await onFileUpload(file)
|
|
142
|
+
if (url) {
|
|
143
|
+
document.execCommand('insertHTML', false, `<img src="${url}" alt="Image collée" style="max-width:100%;height:auto;border-radius:8px;margin:8px 0;" />`)
|
|
144
|
+
editorRef.current?.focus()
|
|
145
|
+
setTimeout(emitChange, 0)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}, [onFileUpload, emitChange])
|
|
152
|
+
|
|
153
|
+
const btn: React.CSSProperties = {
|
|
154
|
+
border: 'none',
|
|
155
|
+
background: 'transparent',
|
|
156
|
+
cursor: 'pointer',
|
|
157
|
+
padding: '8px 10px',
|
|
158
|
+
fontSize: '13px',
|
|
159
|
+
fontWeight: 700,
|
|
160
|
+
color: '#555',
|
|
161
|
+
borderRadius: '5px',
|
|
162
|
+
lineHeight: 1,
|
|
163
|
+
minHeight: '44px',
|
|
164
|
+
minWidth: '44px',
|
|
165
|
+
display: 'inline-flex',
|
|
166
|
+
alignItems: 'center',
|
|
167
|
+
justifyContent: 'center',
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const sep: React.CSSProperties = {
|
|
171
|
+
width: '1px',
|
|
172
|
+
height: '18px',
|
|
173
|
+
background: '#d1d5db',
|
|
174
|
+
margin: '0 4px',
|
|
175
|
+
alignSelf: 'center',
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return (
|
|
179
|
+
<div style={{ border: `3px solid ${focused ? focusBorderColor : borderColor}`, borderRadius: '12px', overflow: 'hidden', transition: 'border-color 0.15s', background: '#fff' }}>
|
|
180
|
+
<style>{`
|
|
181
|
+
.rte-toolbar button:hover { background: #e5e7eb !important; }
|
|
182
|
+
.rte-editor blockquote { border-left: 4px solid #00E5FF; margin: 8px 0; padding: 8px 16px; background: #f0f9ff; border-radius: 0 6px 6px 0; }
|
|
183
|
+
.rte-editor img { max-width: 100%; height: auto; border-radius: 8px; margin: 8px 0; }
|
|
184
|
+
.rte-editor a { color: #00838f; text-decoration: underline; }
|
|
185
|
+
.rte-editor ul, .rte-editor ol { margin: 8px 0; padding-left: 24px; }
|
|
186
|
+
.rte-editor li { margin: 2px 0; }
|
|
187
|
+
.rte-editor p { margin: 0 0 6px 0; }
|
|
188
|
+
`}</style>
|
|
189
|
+
|
|
190
|
+
{/* Toolbar */}
|
|
191
|
+
<div className="rte-toolbar" style={{ display: 'flex', alignItems: 'center', gap: '2px', padding: '6px 10px', borderBottom: '2px solid #e5e7eb', backgroundColor: '#f9fafb', flexWrap: 'wrap' }}>
|
|
192
|
+
<button type="button" style={btn} onMouseDown={(e) => { e.preventDefault(); exec('bold') }} title="Gras (Ctrl+B)">
|
|
193
|
+
<strong>B</strong>
|
|
194
|
+
</button>
|
|
195
|
+
<button type="button" style={btn} onMouseDown={(e) => { e.preventDefault(); exec('italic') }} title="Italique (Ctrl+I)">
|
|
196
|
+
<em style={{ fontStyle: 'italic' }}>I</em>
|
|
197
|
+
</button>
|
|
198
|
+
<button type="button" style={btn} onMouseDown={(e) => { e.preventDefault(); exec('underline') }} title="Souligné (Ctrl+U)">
|
|
199
|
+
<span style={{ textDecoration: 'underline' }}>S</span>
|
|
200
|
+
</button>
|
|
201
|
+
<span style={sep} />
|
|
202
|
+
<button type="button" style={{ ...btn, fontSize: '16px' }} onMouseDown={(e) => { e.preventDefault(); exec('formatBlock', 'blockquote') }} title="Citation">
|
|
203
|
+
“”
|
|
204
|
+
</button>
|
|
205
|
+
<button type="button" style={{ ...btn, fontSize: '12px' }} onMouseDown={(e) => { e.preventDefault(); exec('insertUnorderedList') }} title="Liste à puces">
|
|
206
|
+
• Liste
|
|
207
|
+
</button>
|
|
208
|
+
<button type="button" style={{ ...btn, fontSize: '12px' }} onMouseDown={(e) => { e.preventDefault(); exec('insertOrderedList') }} title="Liste numérotée">
|
|
209
|
+
1. Liste
|
|
210
|
+
</button>
|
|
211
|
+
<span style={sep} />
|
|
212
|
+
<button type="button" style={btn} onMouseDown={(e) => { e.preventDefault(); handleInsertLink() }} title="Insérer un lien">
|
|
213
|
+
🔗
|
|
214
|
+
</button>
|
|
215
|
+
<button type="button" style={btn} onMouseDown={(e) => { e.preventDefault(); handleImageClick() }} title="Insérer une image">
|
|
216
|
+
🖼️
|
|
217
|
+
</button>
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
{/* Editor area */}
|
|
221
|
+
<div style={{ position: 'relative' }}>
|
|
222
|
+
{isEmpty && (
|
|
223
|
+
<div style={{ position: 'absolute', top: 0, left: 0, right: 0, padding: '14px 16px', color: '#9ca3af', fontSize: '14px', pointerEvents: 'none', userSelect: 'none' }}>
|
|
224
|
+
{placeholder}
|
|
225
|
+
</div>
|
|
226
|
+
)}
|
|
227
|
+
<div
|
|
228
|
+
ref={editorRef}
|
|
229
|
+
className="rte-editor"
|
|
230
|
+
contentEditable
|
|
231
|
+
suppressContentEditableWarning
|
|
232
|
+
onInput={emitChange}
|
|
233
|
+
onFocus={() => setFocused(true)}
|
|
234
|
+
onBlur={() => { setFocused(false); emitChange() }}
|
|
235
|
+
onPaste={handlePaste}
|
|
236
|
+
style={{
|
|
237
|
+
minHeight: `${minHeight}px`,
|
|
238
|
+
padding: '14px 16px',
|
|
239
|
+
fontSize: '14px',
|
|
240
|
+
lineHeight: 1.6,
|
|
241
|
+
color: '#1f2937',
|
|
242
|
+
outline: 'none',
|
|
243
|
+
overflowY: 'auto',
|
|
244
|
+
}}
|
|
245
|
+
/>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
{/* Hidden file input for image upload */}
|
|
249
|
+
{onFileUpload && (
|
|
250
|
+
<input
|
|
251
|
+
ref={fileInputRef}
|
|
252
|
+
type="file"
|
|
253
|
+
accept="image/*"
|
|
254
|
+
multiple
|
|
255
|
+
onChange={handleFileChange}
|
|
256
|
+
style={{ display: 'none' }}
|
|
257
|
+
/>
|
|
258
|
+
)}
|
|
259
|
+
</div>
|
|
260
|
+
)
|
|
261
|
+
})
|
|
@@ -149,17 +149,21 @@ function SingleCodeBlock({ lang, code }: { lang: string; code: string }) {
|
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
/**
|
|
152
|
-
*
|
|
153
|
-
* Non-code text is passed through as-is.
|
|
152
|
+
* Checks if text contains fenced code blocks.
|
|
154
153
|
*/
|
|
155
|
-
export function
|
|
156
|
-
|
|
154
|
+
export function hasCodeBlocks(text: string): boolean {
|
|
155
|
+
return /```[\s\S]*?```/.test(text)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Renders the FULL message text, replacing fenced code blocks with styled components.
|
|
160
|
+
* Use this INSTEAD of rendering msg.body directly when code blocks are present.
|
|
161
|
+
*/
|
|
162
|
+
export function MessageWithCodeBlocks({ text, style }: { text: string; style?: React.CSSProperties }) {
|
|
157
163
|
const parts = text.split(/(```[\s\S]*?```)/g)
|
|
158
|
-
const hasCodeBlock = parts.some((p) => p.startsWith('```'))
|
|
159
|
-
if (!hasCodeBlock) return null
|
|
160
164
|
|
|
161
165
|
return (
|
|
162
|
-
|
|
166
|
+
<div style={style}>
|
|
163
167
|
{parts.map((part, i) => {
|
|
164
168
|
if (part.startsWith('```')) {
|
|
165
169
|
const match = part.match(/^```(\w*)\n?([\s\S]*?)```$/)
|
|
@@ -169,18 +173,53 @@ export function CodeBlockRenderer({ text }: { text: string }) {
|
|
|
169
173
|
return <SingleCodeBlock key={i} lang={lang} code={code} />
|
|
170
174
|
}
|
|
171
175
|
}
|
|
172
|
-
|
|
176
|
+
// Render plain text parts
|
|
177
|
+
if (!part) return null
|
|
178
|
+
return <span key={i} style={{ whiteSpace: 'pre-wrap' }}>{part}</span>
|
|
173
179
|
})}
|
|
174
|
-
|
|
180
|
+
</div>
|
|
175
181
|
)
|
|
176
182
|
}
|
|
177
183
|
|
|
178
184
|
/**
|
|
179
|
-
*
|
|
180
|
-
* Also handles ```lang blocks that survived as text in HTML.
|
|
185
|
+
* Converts HTML to plain text while preserving line breaks from block-level tags.
|
|
181
186
|
*/
|
|
187
|
+
function htmlToText(html: string): string {
|
|
188
|
+
return html
|
|
189
|
+
// Convert line breaks first (before stripping)
|
|
190
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
191
|
+
.replace(/<\/(p|div|li|h[1-6]|tr|pre)>/gi, '\n')
|
|
192
|
+
.replace(/<\/?(ul|ol|table|tbody|thead)[^>]*>/gi, '')
|
|
193
|
+
// Strip remaining tags
|
|
194
|
+
.replace(/<[^>]+>/g, '')
|
|
195
|
+
// Decode common HTML entities
|
|
196
|
+
.replace(/ /g, ' ')
|
|
197
|
+
.replace(/&/g, '&')
|
|
198
|
+
.replace(/</g, '<')
|
|
199
|
+
.replace(/>/g, '>')
|
|
200
|
+
.replace(/"/g, '"')
|
|
201
|
+
.replace(/'/g, "'")
|
|
202
|
+
// Collapse 3+ newlines into 2
|
|
203
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
204
|
+
.trim()
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* For HTML content: strips tags (preserving newlines), then renders with code blocks.
|
|
209
|
+
*/
|
|
210
|
+
export function MessageWithCodeBlocksHtml({ html, style }: { html: string; style?: React.CSSProperties }) {
|
|
211
|
+
const text = htmlToText(html)
|
|
212
|
+
if (!hasCodeBlocks(text)) return null
|
|
213
|
+
return <MessageWithCodeBlocks text={text} style={style} />
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Keep old exports for backwards compat (but they're no longer used in message rendering)
|
|
217
|
+
export function CodeBlockRenderer({ text }: { text: string }) {
|
|
218
|
+
if (!hasCodeBlocks(text)) return null
|
|
219
|
+
return <MessageWithCodeBlocks text={text} />
|
|
220
|
+
}
|
|
221
|
+
|
|
182
222
|
export function CodeBlockRendererHtml({ html }: { html: string }) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
return <CodeBlockRenderer text={stripped} />
|
|
223
|
+
const text = htmlToText(html)
|
|
224
|
+
return <CodeBlockRenderer text={text} />
|
|
186
225
|
}
|
package/src/plugin.ts
CHANGED
|
@@ -115,6 +115,8 @@ export function supportPlugin(config?: SupportPluginConfig): Plugin {
|
|
|
115
115
|
'support-settings': viewConfig(`${viewsBase}#TicketingSettingsView`, `${bp}/settings`),
|
|
116
116
|
'support-logs': viewConfig(`${viewsBase}#LogsView`, `${bp}/logs`),
|
|
117
117
|
'support-crm': viewConfig(`${viewsBase}#CrmView`, `${bp}/crm`),
|
|
118
|
+
'support-billing': viewConfig(`${viewsBase}#BillingView`, `${bp}/billing`),
|
|
119
|
+
'support-import': viewConfig(`${viewsBase}#ImportConversationView`, `/import-conversation`),
|
|
118
120
|
}
|
|
119
121
|
|
|
120
122
|
if (features.chat) {
|
|
@@ -75,10 +75,47 @@ export function emailRichContent(html: string, config?: EmailTemplateConfig): st
|
|
|
75
75
|
})
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
// Convert markdown fenced code blocks (```lang\ncode\n```) to styled <pre> blocks
|
|
79
|
+
// Email clients don't run JS so we just use a nice layout, no syntax highlighting
|
|
80
|
+
function convertFencedCodeBlocks(input: string): string {
|
|
81
|
+
// Quick check: no backticks, no work to do
|
|
82
|
+
if (!input.includes('```')) return input
|
|
83
|
+
|
|
84
|
+
// Normalize HTML block breaks to newlines so the regex can span paragraphs
|
|
85
|
+
const normalized = input
|
|
86
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
87
|
+
.replace(/<\/(p|div)>\s*<(p|div)[^>]*>/gi, '\n')
|
|
88
|
+
.replace(/<(p|div)[^>]*>/gi, '')
|
|
89
|
+
.replace(/<\/(p|div)>/gi, '\n')
|
|
90
|
+
|
|
91
|
+
return normalized.replace(/```(\w*)\n?([\s\S]*?)```/g, (_match, lang, code) => {
|
|
92
|
+
const cleanCode = escapeHtml((code || '').replace(/\n$/, ''))
|
|
93
|
+
const label = lang ? lang.trim() : 'code'
|
|
94
|
+
return `<div style="margin: 16px 0; border: 1px solid #1f2937; border-radius: 8px; overflow: hidden; background: #0f172a;">
|
|
95
|
+
<div style="padding: 6px 14px; background: #1e293b; border-bottom: 1px solid #334155; font-family: 'SF Mono', 'Fira Code', Consolas, monospace; font-size: 11px; font-weight: 700; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em;">${escapeHtml(label)}</div>
|
|
96
|
+
<pre style="margin: 0; padding: 14px 16px; overflow-x: auto; background: #0f172a; color: #e2e8f0; font-family: 'SF Mono', 'Fira Code', Consolas, monospace; font-size: 13px; line-height: 1.6; white-space: pre;"><code style="background: transparent; padding: 0; color: inherit; font-family: inherit; font-size: inherit;">${cleanCode}</code></pre>
|
|
97
|
+
</div>`
|
|
98
|
+
})
|
|
99
|
+
// Re-wrap non-code text chunks in paragraphs (split by our <div> blocks)
|
|
100
|
+
.split(/(<div style="margin: 16px 0[\s\S]*?<\/div>)/g)
|
|
101
|
+
.map((chunk) => {
|
|
102
|
+
if (chunk.startsWith('<div style="margin: 16px 0')) return chunk
|
|
103
|
+
// Non-code chunk: wrap text lines in <p>
|
|
104
|
+
return chunk
|
|
105
|
+
.split('\n')
|
|
106
|
+
.map((line) => line.trim() ? `<p>${line}</p>` : '')
|
|
107
|
+
.join('')
|
|
108
|
+
})
|
|
109
|
+
.join('')
|
|
110
|
+
}
|
|
111
|
+
|
|
78
112
|
let result = html
|
|
79
113
|
// Make relative image URLs absolute
|
|
80
114
|
.replace(/src="\/([^"]+)"/g, `src="${baseUrl}/$1"`)
|
|
81
115
|
|
|
116
|
+
// Convert fenced code blocks BEFORE other processing (protects code from HTML styling)
|
|
117
|
+
result = convertFencedCodeBlocks(result)
|
|
118
|
+
|
|
82
119
|
// Apply inline styles to elements
|
|
83
120
|
result = styleTag(result, 'blockquote', `border-left: 4px solid ${c.brandColor}; margin: 16px 0; padding: 12px 20px; background: #f0f9fa; border-radius: 0 8px 8px 0;`)
|
|
84
121
|
result = styleTag(result, 'img', 'max-width: 100%; height: auto; border-radius: 8px; margin: 12px 0; display: block;')
|