@cognigy/chat-components-vue 0.1.0

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.
Files changed (81) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +178 -0
  3. package/dist/assets/svg/ArrowBackIcon.vue.d.ts +9 -0
  4. package/dist/assets/svg/AudioPauseIcon.vue.d.ts +2 -0
  5. package/dist/assets/svg/AudioPlayIcon.vue.d.ts +2 -0
  6. package/dist/assets/svg/CloseIcon.vue.d.ts +2 -0
  7. package/dist/assets/svg/DownloadIcon.vue.d.ts +2 -0
  8. package/dist/assets/svg/VideoPlayIcon.vue.d.ts +9 -0
  9. package/dist/assets/svg/index.d.ts +7 -0
  10. package/dist/chat-components-vue.css +1 -0
  11. package/dist/chat-components-vue.js +9858 -0
  12. package/dist/components/Message.vue.d.ts +11 -0
  13. package/dist/components/common/ActionButton.vue.d.ts +59 -0
  14. package/dist/components/common/ActionButtons.vue.d.ts +36 -0
  15. package/dist/components/common/ChatBubble.vue.d.ts +22 -0
  16. package/dist/components/common/ChatEvent.vue.d.ts +20 -0
  17. package/dist/components/common/LinkIcon.vue.d.ts +2 -0
  18. package/dist/components/common/TypingIndicator.vue.d.ts +21 -0
  19. package/dist/components/common/Typography.vue.d.ts +38 -0
  20. package/dist/components/messages/AdaptiveCard.vue.d.ts +2 -0
  21. package/dist/components/messages/AudioMessage.vue.d.ts +5 -0
  22. package/dist/components/messages/DatePicker.vue.d.ts +2 -0
  23. package/dist/components/messages/FileMessage.vue.d.ts +2 -0
  24. package/dist/components/messages/Gallery.vue.d.ts +2 -0
  25. package/dist/components/messages/GalleryItem.vue.d.ts +7 -0
  26. package/dist/components/messages/ImageMessage.vue.d.ts +5 -0
  27. package/dist/components/messages/List.vue.d.ts +2 -0
  28. package/dist/components/messages/ListItem.vue.d.ts +16 -0
  29. package/dist/components/messages/TextMessage.vue.d.ts +15 -0
  30. package/dist/components/messages/TextWithButtons.vue.d.ts +2 -0
  31. package/dist/components/messages/VideoMessage.vue.d.ts +5 -0
  32. package/dist/composables/useChannelPayload.d.ts +47 -0
  33. package/dist/composables/useCollation.d.ts +47 -0
  34. package/dist/composables/useImageContext.d.ts +13 -0
  35. package/dist/composables/useMessageContext.d.ts +18 -0
  36. package/dist/composables/useSanitize.d.ts +8 -0
  37. package/dist/index.d.ts +33 -0
  38. package/dist/types/index.d.ts +275 -0
  39. package/dist/utils/helpers.d.ts +56 -0
  40. package/dist/utils/matcher.d.ts +20 -0
  41. package/dist/utils/sanitize.d.ts +28 -0
  42. package/dist/utils/theme.d.ts +18 -0
  43. package/package.json +94 -0
  44. package/src/assets/svg/ArrowBackIcon.vue +30 -0
  45. package/src/assets/svg/AudioPauseIcon.vue +20 -0
  46. package/src/assets/svg/AudioPlayIcon.vue +19 -0
  47. package/src/assets/svg/CloseIcon.vue +10 -0
  48. package/src/assets/svg/DownloadIcon.vue +10 -0
  49. package/src/assets/svg/VideoPlayIcon.vue +25 -0
  50. package/src/assets/svg/index.ts +7 -0
  51. package/src/components/Message.vue +152 -0
  52. package/src/components/common/ActionButton.vue +354 -0
  53. package/src/components/common/ActionButtons.vue +170 -0
  54. package/src/components/common/ChatBubble.vue +109 -0
  55. package/src/components/common/ChatEvent.vue +84 -0
  56. package/src/components/common/LinkIcon.vue +34 -0
  57. package/src/components/common/TypingIndicator.vue +202 -0
  58. package/src/components/common/Typography.vue +196 -0
  59. package/src/components/messages/AdaptiveCard.vue +292 -0
  60. package/src/components/messages/AudioMessage.vue +391 -0
  61. package/src/components/messages/DatePicker.vue +135 -0
  62. package/src/components/messages/FileMessage.vue +195 -0
  63. package/src/components/messages/Gallery.vue +296 -0
  64. package/src/components/messages/GalleryItem.vue +214 -0
  65. package/src/components/messages/ImageMessage.vue +368 -0
  66. package/src/components/messages/List.vue +149 -0
  67. package/src/components/messages/ListItem.vue +344 -0
  68. package/src/components/messages/TextMessage.vue +203 -0
  69. package/src/components/messages/TextWithButtons.vue +119 -0
  70. package/src/components/messages/VideoMessage.vue +343 -0
  71. package/src/composables/useChannelPayload.ts +101 -0
  72. package/src/composables/useCollation.ts +163 -0
  73. package/src/composables/useImageContext.ts +27 -0
  74. package/src/composables/useMessageContext.ts +41 -0
  75. package/src/composables/useSanitize.ts +25 -0
  76. package/src/index.ts +71 -0
  77. package/src/types/index.ts +373 -0
  78. package/src/utils/helpers.ts +164 -0
  79. package/src/utils/matcher.ts +283 -0
  80. package/src/utils/sanitize.ts +133 -0
  81. package/src/utils/theme.ts +58 -0
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Helper utilities
3
+ */
4
+
5
+ import type {IWebchatButton, IWebchatQuickReply} from '../types'
6
+
7
+ /**
8
+ * Gets the label for a button
9
+ * Returns "Call" for phone_number buttons without title
10
+ */
11
+ export function getWebchatButtonLabel(
12
+ button: IWebchatButton | IWebchatQuickReply
13
+ ): string | undefined {
14
+ const { title } = button
15
+
16
+ if (!title && 'type' in button && button.type === 'phone_number') {
17
+ return 'Call'
18
+ }
19
+
20
+ return title
21
+ }
22
+
23
+ /**
24
+ * Interpolates a template string with replacements
25
+ * Example: interpolateString("{position} of {total}", { position: "1", total: "4" })
26
+ * Returns: "1 of 4"
27
+ */
28
+ export function interpolateString(
29
+ template: string,
30
+ replacements: Record<string, string>
31
+ ): string {
32
+ return template.replace(/{(\w+)}/g, (_, key) => {
33
+ return key in replacements ? replacements[key] : ''
34
+ })
35
+ }
36
+
37
+ /**
38
+ * Generates a random ID with optional prefix
39
+ */
40
+ export function getRandomId(prefix = ''): string {
41
+ const id = window?.crypto?.randomUUID?.() || Date.now().toString()
42
+ return prefix ? `${prefix}-${id}` : id.toString()
43
+ }
44
+
45
+ /**
46
+ * Move focus to the visually hidden focus target
47
+ * This prevents focus loss for keyboard users
48
+ */
49
+ export function moveFocusToMessageFocusTarget(dataMessageId: string): void {
50
+ setTimeout(() => {
51
+ const focusElement = document.getElementById(`webchat-focus-target-${dataMessageId}`)
52
+ if (focusElement) {
53
+ focusElement.focus({ preventScroll: true })
54
+ }
55
+ }, 0)
56
+ }
57
+
58
+ /**
59
+ * Helper function that replaces URLs in a string with HTML anchor elements
60
+ * - Works with URLs starting with http/https, www., or just domain/subdomain
61
+ * - Will only match URLs at the beginning or following whitespace
62
+ * - Will not work with emails
63
+ */
64
+ export function replaceUrlsWithHTMLanchorElem(text: string): string {
65
+ // Enhanced regex to capture URLs with parameters
66
+ const urlMatcherRegex =
67
+ /(^|\s)(\b(https?):\/\/([-A-Z0-9+&@$#/%?=~_|!:,.;\p{L}]*[-A-Z0-9+&$@#/%=~_|\p{L}]))/giu
68
+
69
+ return text.replace(urlMatcherRegex, (url) => {
70
+ return `<a href="${url}" target="_blank">${url}</a>`
71
+ })
72
+ }
73
+
74
+ /**
75
+ * Sanitizes a URL for use in CSS background-image property
76
+ * Returns url("...") string or undefined if invalid
77
+ */
78
+ export function getBackgroundImage(url: string): string | undefined {
79
+ if (!url) return undefined
80
+
81
+ // Remove control characters that could break CSS parsing
82
+ let sanitized = url.replace(/[\r\n\f]/g, '')
83
+
84
+ // If the string looks like an absolute URL (has a scheme), validate allowed protocols (http/https).
85
+ if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(sanitized)) {
86
+ try {
87
+ const parsed = new URL(sanitized)
88
+ if (!/^https?:$/i.test(parsed.protocol)) {
89
+ return undefined
90
+ }
91
+ // Normalize absolute URLs
92
+ sanitized = parsed.href
93
+ } catch {
94
+ // URL constructor failed (invalid absolute URL). Reject.
95
+ return undefined
96
+ }
97
+ }
98
+
99
+ // Escape characters that could terminate or escape the quoted url("...") context.
100
+ sanitized = sanitized
101
+ .replace(/\\/g, '\\\\') // Escape backslashes first
102
+ .replace(/"/g, '\\"') // Escape double quotes
103
+ .replace(/\)/g, '\\)') // Escape closing parenthesis
104
+
105
+ return `url("${sanitized}")`
106
+ }
107
+
108
+ /**
109
+ * File attachment helpers
110
+ */
111
+
112
+ const ONE_MB = 1000000
113
+ const ONE_KB = 1000
114
+
115
+ /**
116
+ * Extracts filename without extension
117
+ * Example: "document.pdf" → "document."
118
+ */
119
+ export function getFileName(fileNameWithExtension: string): string {
120
+ const splitName = fileNameWithExtension.split('.')
121
+ if (splitName.length > 1) {
122
+ return `${splitName.slice(0, -1).join('.')}.`
123
+ } else {
124
+ // return full name here if it didn't have a file ending
125
+ return fileNameWithExtension
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Extracts file extension
131
+ * Example: "document.pdf" → "pdf"
132
+ */
133
+ export function getFileExtension(fileNameWithExtension: string): string | null {
134
+ const splitName = fileNameWithExtension.split('.')
135
+ if (splitName.length > 1) {
136
+ return splitName.pop() || null
137
+ } else {
138
+ return null
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Formats file size in MB or KB
144
+ * Example: 1500000 → "1.50 MB"
145
+ */
146
+ export function getSizeLabel(size: number): string {
147
+ if (size > ONE_MB) {
148
+ return `${(size / ONE_MB).toFixed(2)} MB`
149
+ }
150
+
151
+ return `${(size / ONE_KB).toFixed(2)} KB`
152
+ }
153
+
154
+ /**
155
+ * Valid image MIME types for file attachments
156
+ */
157
+ export const VALID_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
158
+
159
+ /**
160
+ * Checks if attachment is a valid image type
161
+ */
162
+ export function isImageAttachment(mimeType: string): boolean {
163
+ return VALID_IMAGE_MIME_TYPES.includes(mimeType)
164
+ }
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Message matcher system
3
+ * Determines which component to render based on message data structure
4
+ *
5
+ * This is the core of the data-driven architecture.
6
+ * The same matching rules as the React version.
7
+ */
8
+
9
+ import type { IMessage } from '@cognigy/socket-client'
10
+ import type { ChatConfig, MatchRule, MessagePlugin, MatchResult } from '../types'
11
+ import { isAdaptiveCardPayload } from '../types'
12
+
13
+ /**
14
+ * Check if message has channel payload
15
+ */
16
+ export function getChannelPayload(message: IMessage, config?: ChatConfig) {
17
+ if (!message?.data?._cognigy) {
18
+ return undefined
19
+ }
20
+
21
+ const { _facebook, _webchat, _defaultPreview } = message.data._cognigy
22
+
23
+ // Check default preview first
24
+ const defaultPreviewEnabled = config?.settings?.widgetSettings?.enableDefaultPreview
25
+ if (defaultPreviewEnabled && _defaultPreview) {
26
+ return _defaultPreview
27
+ }
28
+
29
+ // Check messenger sync
30
+ const strictMessengerSync = config?.settings?.widgetSettings?.enableStrictMessengerSync
31
+ const shouldSyncWithFacebook = message.data._cognigy.syncWebchatWithFacebook
32
+ if (strictMessengerSync && shouldSyncWithFacebook && _facebook) {
33
+ return _facebook
34
+ }
35
+
36
+ // Return webchat or facebook as fallback
37
+ return _webchat || _facebook
38
+ }
39
+
40
+ /**
41
+ * Check if text is only escape sequences (whitespace, newlines, etc.)
42
+ */
43
+ function isOnlyEscapeSequence(text: string | null | undefined): boolean {
44
+ if (typeof text !== 'string') {
45
+ return false
46
+ }
47
+
48
+ const trimmed = text.trim()
49
+ return trimmed === '' || /^[\n\t\r\v\f\s]*$/.test(trimmed)
50
+ }
51
+
52
+ /**
53
+ * Default match rules for internal message types.
54
+ * These rules map message data structures to component names.
55
+ * Components are resolved by name lookup in Message.vue.
56
+ */
57
+ export function createDefaultMatchRules(): MatchRule[] {
58
+ return [
59
+ // xApp submit
60
+ {
61
+ name: 'XAppSubmit',
62
+ match: (message) => {
63
+ return message?.data?._plugin?.type === 'x-app-submit'
64
+ },
65
+ },
66
+
67
+ // Webchat3Event
68
+ {
69
+ name: 'Webchat3Event',
70
+ match: (message) => {
71
+ return !!message?.data?._cognigy?._webchat3?.type
72
+ },
73
+ },
74
+
75
+ // Date picker
76
+ {
77
+ name: 'DatePicker',
78
+ match: (message) => {
79
+ return message?.data?._plugin?.type === 'date-picker'
80
+ },
81
+ },
82
+
83
+ // Text with buttons / Quick Replies
84
+ {
85
+ name: 'TextWithButtons',
86
+ match: (message, config) => {
87
+ const channelConfig = getChannelPayload(message, config)
88
+ if (!channelConfig) {
89
+ return false
90
+ }
91
+
92
+ const hasQuickReplies =
93
+ channelConfig.message?.quick_replies &&
94
+ channelConfig.message.quick_replies.length > 0
95
+
96
+ const isButtonTemplate =
97
+ channelConfig.message?.attachment?.payload?.template_type === 'button'
98
+
99
+ const hasMessengerText = !!channelConfig.message?.text
100
+
101
+ const isDefaultPreviewEnabled = config?.settings?.widgetSettings?.enableDefaultPreview
102
+ const hasDefaultPreview = !!message?.data?._cognigy?._defaultPreview
103
+ const shouldSkip = isDefaultPreviewEnabled && !hasDefaultPreview && message.text
104
+
105
+ return !shouldSkip && (hasQuickReplies || isButtonTemplate || hasMessengerText)
106
+ },
107
+ },
108
+
109
+ // Image
110
+ {
111
+ name: 'Image',
112
+ match: (message, config) => {
113
+ const channelConfig = getChannelPayload(message, config)
114
+ if (!channelConfig) {
115
+ return false
116
+ }
117
+
118
+ return channelConfig.message?.attachment?.type === 'image'
119
+ },
120
+ },
121
+
122
+ // Video
123
+ {
124
+ name: 'Video',
125
+ match: (message, config) => {
126
+ const channelConfig = getChannelPayload(message, config)
127
+ if (!channelConfig) {
128
+ return false
129
+ }
130
+
131
+ return channelConfig.message?.attachment?.type === 'video'
132
+ },
133
+ },
134
+
135
+ // Audio
136
+ {
137
+ name: 'Audio',
138
+ match: (message, config) => {
139
+ const channelConfig = getChannelPayload(message, config)
140
+ if (!channelConfig) {
141
+ return false
142
+ }
143
+
144
+ return channelConfig.message?.attachment?.type === 'audio'
145
+ },
146
+ },
147
+
148
+ // File
149
+ {
150
+ name: 'File',
151
+ match: (message) => {
152
+ return !!message?.data?.attachments
153
+ },
154
+ },
155
+
156
+ // List
157
+ {
158
+ name: 'List',
159
+ match: (message, config) => {
160
+ const channelConfig = getChannelPayload(message, config)
161
+ if (!channelConfig) {
162
+ return false
163
+ }
164
+
165
+ return channelConfig.message?.attachment?.payload?.template_type === 'list'
166
+ },
167
+ },
168
+
169
+ // Gallery
170
+ {
171
+ name: 'Gallery',
172
+ match: (message, config) => {
173
+ const channelConfig = getChannelPayload(message, config)
174
+ if (!channelConfig) {
175
+ return false
176
+ }
177
+
178
+ return channelConfig.message?.attachment?.payload?.template_type === 'generic'
179
+ },
180
+ },
181
+
182
+ // Adaptive Card
183
+ {
184
+ name: 'AdaptiveCard',
185
+ match: (message, config) => {
186
+ const webchatPayload = message?.data?._cognigy?._webchat
187
+ const defaultPreviewPayload = message?.data?._cognigy?._defaultPreview
188
+ const hasWebchatAdaptiveCard = isAdaptiveCardPayload(webchatPayload)
189
+ const hasDefaultPreviewAdaptiveCard = isAdaptiveCardPayload(defaultPreviewPayload)
190
+ const isPluginAdaptiveCard = message?.data?._plugin?.type === 'adaptivecards'
191
+ const defaultPreviewEnabled = config?.settings?.widgetSettings?.enableDefaultPreview
192
+
193
+ // Skip if default preview has a message and is enabled
194
+ if (defaultPreviewPayload?.message && defaultPreviewEnabled) {
195
+ return false
196
+ }
197
+
198
+ return (
199
+ (hasDefaultPreviewAdaptiveCard && defaultPreviewEnabled) ||
200
+ (hasWebchatAdaptiveCard && hasDefaultPreviewAdaptiveCard && !defaultPreviewEnabled) ||
201
+ hasWebchatAdaptiveCard ||
202
+ isPluginAdaptiveCard
203
+ )
204
+ },
205
+ },
206
+
207
+ // Text message (fallback)
208
+ {
209
+ name: 'Text',
210
+ match: (message, config) => {
211
+ // Don't render engagement messages unless configured
212
+ if (
213
+ message?.source === 'engagement' &&
214
+ !config?.settings?.layout?.showEngagementInChat
215
+ ) {
216
+ return false
217
+ }
218
+
219
+ // Don't render if has file attachments
220
+ if (message?.data?.attachments) {
221
+ return false
222
+ }
223
+
224
+ // Handle message arrays (from streaming mode)
225
+ if (Array.isArray(message?.text)) {
226
+ return message.text.length > 0
227
+ }
228
+
229
+ // Handle messages with only escape sequences
230
+ if (
231
+ isOnlyEscapeSequence(message.text) &&
232
+ !config?.settings?.behavior?.collateStreamedOutputs
233
+ ) {
234
+ return false
235
+ }
236
+
237
+ return message?.text !== null &&
238
+ message?.text !== undefined &&
239
+ message?.text !== ''
240
+ },
241
+ },
242
+ ]
243
+ }
244
+
245
+ /**
246
+ * Match a message to component(s)
247
+ * @param message - The message to match
248
+ * @param config - Optional configuration
249
+ * @param externalPlugins - Custom plugins to check first (these provide their own components)
250
+ * @returns Array of matched rules/plugins
251
+ */
252
+ export function match(
253
+ message: IMessage,
254
+ config?: ChatConfig,
255
+ externalPlugins: MessagePlugin[] = []
256
+ ): MatchResult[] {
257
+ // Combine external plugins with default rules
258
+ // External plugins are checked first
259
+ const allRules: MatchResult[] = [...externalPlugins, ...createDefaultMatchRules()]
260
+
261
+ const matchedRules: MatchResult[] = []
262
+
263
+ for (const rule of allRules) {
264
+ try {
265
+ if (rule.match(message, config)) {
266
+ matchedRules.push(rule)
267
+
268
+ // Stop matching unless passthrough is enabled
269
+ if (!rule.options?.passthrough) {
270
+ break
271
+ }
272
+ }
273
+ } catch (error) {
274
+ console.error('Matcher: Error in match function', {
275
+ ruleName: rule.name,
276
+ error,
277
+ messageId: message.traceId,
278
+ })
279
+ }
280
+ }
281
+
282
+ return matchedRules
283
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * HTML sanitization utilities
3
+ * Uses DOMPurify to sanitize HTML content
4
+ */
5
+
6
+ import DOMPurify from 'dompurify'
7
+
8
+ /**
9
+ * Default allowed HTML tags
10
+ * Same as React version
11
+ */
12
+ export const allowedHtmlTags = [
13
+ 'a', 'abbr', 'acronym', 'address', 'area', 'article', 'aside', 'audio',
14
+ 'b', 'bdi', 'bdo', 'big', 'blockquote', 'br', 'button',
15
+ 'canvas', 'caption', 'center', 'cite', 'code', 'col', 'colgroup',
16
+ 'data', 'datalist', 'dd', 'del', 'details', 'dfn', 'dialog', 'div', 'dl', 'dt',
17
+ 'em', 'embed',
18
+ 'fieldset', 'figcaption', 'figure', 'footer', 'form',
19
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hr',
20
+ 'i', 'iframe', 'img', 'input', 'ins',
21
+ 'kbd',
22
+ 'label', 'legend', 'li', 'link',
23
+ 'main', 'map', 'mark', 'meta', 'meter',
24
+ 'nav',
25
+ 'ol', 'optgroup', 'option', 'output',
26
+ 'p', 'param', 'picture', 'pre', 'progress',
27
+ 'q',
28
+ 'rp', 'rt', 'ruby',
29
+ 's', 'samp', 'section', 'select', 'small', 'source', 'span', 'strong', 'style', 'sub', 'summary', 'sup', 'svg',
30
+ 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead', 'time', 'title', 'tr', 'track',
31
+ 'u', 'ul',
32
+ 'var', 'video',
33
+ 'wbr',
34
+ ]
35
+
36
+ /**
37
+ * Default allowed HTML attributes
38
+ */
39
+ export const allowedHtmlAttributes = [
40
+ 'accept', 'accept-charset', 'accesskey', 'action', 'align', 'alt', 'autocomplete', 'autofocus', 'autoplay',
41
+ 'bgcolor', 'border',
42
+ 'charset', 'checked', 'cite', 'class', 'color', 'cols', 'colspan', 'content', 'contenteditable', 'controls', 'coords',
43
+ 'data', 'data-*', 'datetime', 'default', 'dir', 'dirname', 'disabled', 'download', 'draggable', 'dropzone',
44
+ 'enctype',
45
+ 'for', 'form', 'formaction',
46
+ 'headers', 'height', 'hidden', 'high', 'href', 'hreflang', 'http-equiv',
47
+ 'id', 'ismap',
48
+ 'kind',
49
+ 'label', 'lang', 'list', 'loop', 'low',
50
+ 'max', 'maxlength', 'media', 'method', 'min', 'multiple', 'muted',
51
+ 'name', 'novalidate',
52
+ 'open', 'optimum',
53
+ 'pattern', 'placeholder', 'poster', 'preload',
54
+ 'readonly', 'rel', 'required', 'reversed', 'rows', 'rowspan',
55
+ 'sandbox', 'scope', 'selected', 'shape', 'size', 'sizes', 'span', 'spellcheck', 'src', 'srcdoc', 'srclang', 'srcset', 'start', 'step', 'style',
56
+ 'tabindex', 'target', 'title', 'translate', 'type',
57
+ 'usemap',
58
+ 'value',
59
+ 'width', 'wrap',
60
+ ]
61
+
62
+ /**
63
+ * Sanitize HTML with custom configuration
64
+ * @param text - HTML text to sanitize
65
+ * @param customAllowedHtmlTags - Optional custom allowed tags
66
+ * @returns Sanitized HTML string
67
+ */
68
+ export function sanitizeHTMLWithConfig(
69
+ text: string,
70
+ customAllowedHtmlTags?: string[]
71
+ ): string {
72
+ if (!text) {
73
+ return ''
74
+ }
75
+
76
+ // Handle orphan closing tags (from streaming LLMs)
77
+ if (text.startsWith('</')) {
78
+ return text.replace(/</g, '&lt;').replace(/>/g, '&gt;')
79
+ }
80
+
81
+ // Configure DOMPurify
82
+ const config = {
83
+ ALLOWED_TAGS: customAllowedHtmlTags || allowedHtmlTags,
84
+ ALLOWED_ATTR: allowedHtmlAttributes,
85
+ }
86
+
87
+ // Add hook for unknown elements
88
+ // Note: DOMPurify's hook callback types don't fully describe the node parameter
89
+ DOMPurify.addHook('beforeSanitizeElements', (node: any) => {
90
+ if (node instanceof HTMLUnknownElement) {
91
+ const unClosedTag = `<${node.tagName.toLowerCase()}>${node.innerHTML}`
92
+ const closedTag = `<${node.tagName.toLowerCase()}>${node.innerHTML}</${node.tagName.toLowerCase()}>`
93
+ node.replaceWith(unClosedTag === text ? unClosedTag : closedTag)
94
+ }
95
+ })
96
+
97
+ try {
98
+ const sanitized = DOMPurify.sanitize(text, config).toString()
99
+ return sanitized
100
+ } catch (error) {
101
+ console.error('sanitizeHTMLWithConfig: Sanitization failed', {
102
+ error,
103
+ textLength: text.length,
104
+ })
105
+ // Return escaped text as fallback
106
+ return text.replace(/</g, '&lt;').replace(/>/g, '&gt;')
107
+ } finally {
108
+ DOMPurify.removeAllHooks()
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Sanitize content if sanitization is enabled
114
+ * @param content - Content to sanitize
115
+ * @param isSanitizeEnabled - Whether sanitization is enabled
116
+ * @param customAllowedHtmlTags - Custom allowed tags
117
+ * @returns Sanitized or raw content
118
+ */
119
+ export function sanitizeContent(
120
+ content: string | undefined,
121
+ isSanitizeEnabled: boolean,
122
+ customAllowedHtmlTags?: string[]
123
+ ): string {
124
+ if (!content) {
125
+ return ''
126
+ }
127
+
128
+ if (!isSanitizeEnabled) {
129
+ return content
130
+ }
131
+
132
+ return sanitizeHTMLWithConfig(content, customAllowedHtmlTags)
133
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Theme utilities for config-driven CSS variable injection
3
+ */
4
+
5
+ import type { ChatSettings } from '../types'
6
+
7
+ /**
8
+ * Maps config.settings.colors to CSS custom properties.
9
+ * Returns an object suitable for use as an inline style that sets CSS variables.
10
+ *
11
+ * @param colors - The colors object from ChatSettings
12
+ * @returns A record of CSS variable names to their values
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * const style = configColorsToCssVariables({
17
+ * primaryColor: '#0b3694',
18
+ * botMessageColor: '#f5f5f5',
19
+ * })
20
+ * // Returns: { '--cc-primary-color': '#0b3694', '--cc-background-bot-message': '#f5f5f5' }
21
+ * ```
22
+ */
23
+ export function configColorsToCssVariables(
24
+ colors?: ChatSettings['colors']
25
+ ): Record<string, string> {
26
+ if (!colors) return {}
27
+
28
+ const mapping: Record<string, string | undefined> = {
29
+ // Primary action colors
30
+ '--cc-primary-color': colors.primaryColor,
31
+ '--cc-primary-color-hover': colors.primaryColorHover,
32
+ '--cc-primary-color-focus': colors.primaryColorFocus,
33
+ '--cc-primary-color-disabled': colors.primaryColorDisabled,
34
+ '--cc-primary-contrast-color': colors.primaryContrastColor,
35
+ '--cc-secondary-color': colors.secondaryColor,
36
+
37
+ // Message bubble backgrounds
38
+ '--cc-background-bot-message': colors.botMessageColor,
39
+ '--cc-bot-message-contrast-color': colors.botMessageContrastColor,
40
+ '--cc-background-user-message': colors.userMessageColor,
41
+ '--cc-user-message-contrast-color': colors.userMessageContrastColor,
42
+ '--cc-background-agent-message': colors.agentMessageColor,
43
+ '--cc-agent-message-contrast-color': colors.agentMessageContrastColor,
44
+
45
+ // Message bubble borders
46
+ '--cc-border-bot-message': colors.borderBotMessage,
47
+ '--cc-border-user-message': colors.borderUserMessage,
48
+ '--cc-border-agent-message': colors.borderAgentMessage,
49
+
50
+ // Link color
51
+ '--cc-text-link-color': colors.textLinkColor,
52
+ }
53
+
54
+ // Filter out undefined values and return only defined CSS variables
55
+ return Object.fromEntries(
56
+ Object.entries(mapping).filter(([, value]) => value !== undefined)
57
+ ) as Record<string, string>
58
+ }