@cognigy/chat-components-vue 0.1.0 → 0.2.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.
@@ -0,0 +1,260 @@
1
+ <template>
2
+ <div ref="targetRef" data-testid="adaptive-card-renderer" />
3
+ </template>
4
+
5
+ <script setup lang="ts">
6
+ /**
7
+ * AdaptiveCardRenderer - Inner component that uses Microsoft's adaptivecards library
8
+ *
9
+ * This component handles the actual rendering of Adaptive Cards using the
10
+ * Microsoft adaptivecards library. It's designed for presentation-only mode
11
+ * (no action handling).
12
+ *
13
+ * Inspired by Microsoft's adaptivecards-react package:
14
+ * https://github.com/microsoft/AdaptiveCards/blob/5b66a52e0e0cee5074a42dcbe688d608e0327ae4/source/nodejs/adaptivecards-react/src/adaptive-card.tsx
15
+ */
16
+ import { ref, shallowRef, watch, onMounted, onBeforeUnmount } from 'vue'
17
+ import { AdaptiveCard as MSAdaptiveCard, HostConfig } from 'adaptivecards'
18
+ import MarkdownIt from 'markdown-it'
19
+ import { sanitizeHTMLWithConfig } from '../../utils/sanitize'
20
+
21
+ /**
22
+ * Shared MarkdownIt instance at module scope.
23
+ *
24
+ * Why this is safe to share across component instances:
25
+ * - MarkdownIt's render() method is a pure function (input → output, no side effects)
26
+ * - No internal state is mutated during rendering
27
+ * - Configuration is set at construction time and doesn't change
28
+ * - This pattern is recommended by MarkdownIt documentation for performance
29
+ *
30
+ * Creating a new instance per component would be wasteful since there's no
31
+ * per-instance state to isolate.
32
+ */
33
+ const md = new MarkdownIt()
34
+
35
+ /**
36
+ * Set up Markdown processing for Adaptive Cards (module-level, runs once)
37
+ *
38
+ * This sets a static callback on MSAdaptiveCard that processes markdown text.
39
+ * We intentionally do this at module scope to:
40
+ * 1. Avoid race conditions from multiple components setting the callback
41
+ * 2. Ensure consistent markdown processing across all card instances
42
+ *
43
+ * Note: We use sanitizeHTML directly (not via useSanitize composable) because
44
+ * this runs at module scope without component context. For adaptive cards,
45
+ * sanitization is always applied for security - config overrides don't apply.
46
+ */
47
+ MSAdaptiveCard.onProcessMarkdown = (text, result) => {
48
+ try {
49
+ const html = md.render(text)
50
+ // Use default sanitization (no custom allowed tags for adaptive card content)
51
+ result.outputHtml = sanitizeHTMLWithConfig(html)
52
+ result.didProcess = true
53
+ } catch (error) {
54
+ console.error('AdaptiveCardRenderer: Markdown processing failed', { error })
55
+ result.outputHtml = text
56
+ result.didProcess = true
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Host config input type - plain object that HostConfig constructor parses.
62
+ * We don't use Partial<HostConfig> because HostConfig properties are class instances,
63
+ * but the constructor accepts plain objects and converts them internally.
64
+ */
65
+ type HostConfigInput = Record<string, unknown>
66
+
67
+ interface Props {
68
+ /**
69
+ * The Adaptive Card payload to render
70
+ */
71
+ payload?: Record<string, unknown>
72
+ /**
73
+ * Host configuration for styling the card.
74
+ * Accepts a plain object matching the HostConfig structure - the library
75
+ * parses this internally when constructing the HostConfig instance.
76
+ */
77
+ hostConfig?: HostConfigInput
78
+ /**
79
+ * When true, disables all inputs and buttons after rendering
80
+ * Useful for displaying submitted cards in chat history
81
+ */
82
+ readonly?: boolean
83
+ /**
84
+ * Data to pre-fill into input fields
85
+ * Keys should match input element IDs in the card
86
+ * Used to show submitted values in chat history
87
+ */
88
+ inputData?: Record<string, unknown>
89
+ }
90
+
91
+ const props = withDefaults(defineProps<Props>(), {
92
+ readonly: false,
93
+ })
94
+
95
+ const targetRef = ref<HTMLDivElement | null>(null)
96
+
97
+ // Component-scoped card instance - using shallowRef for proper instance isolation
98
+ // Each component gets its own MSAdaptiveCard instance to prevent conflicts
99
+ const cardInstance = shallowRef<MSAdaptiveCard | null>(null)
100
+
101
+ /**
102
+ * Apply input data to card payload by setting values on input elements
103
+ * Recursively walks the card structure to find inputs and set their values
104
+ */
105
+ function applyInputData(
106
+ cardPayload: Record<string, unknown>,
107
+ inputData: Record<string, unknown>
108
+ ): Record<string, unknown> {
109
+ if (!inputData || Object.keys(inputData).length === 0) {
110
+ return cardPayload
111
+ }
112
+
113
+ // Deep clone to avoid mutating original
114
+ const payload = structuredClone(cardPayload)
115
+
116
+ function processElement(element: Record<string, unknown>): void {
117
+ // Check if this is an input element with an id
118
+ const type = element.type as string | undefined
119
+ const id = element.id as string | undefined
120
+
121
+ if (type?.startsWith('Input.') && id && id in inputData) {
122
+ // Set the value for this input
123
+ const value = inputData[id]
124
+
125
+ if (type === 'Input.Toggle') {
126
+ // Toggle inputs use valueOn/valueOff - custom values take precedence
127
+ const matchesCustomOn = element.valueOn !== undefined && value === element.valueOn
128
+ const matchesCustomOff = element.valueOff !== undefined && value === element.valueOff
129
+ const isStandardOn = value === true || value === 'true'
130
+
131
+ const isToggleOn = matchesCustomOn || (!matchesCustomOff && isStandardOn)
132
+
133
+ element.value = isToggleOn
134
+ ? (element.valueOn ?? 'true')
135
+ : (element.valueOff ?? 'false')
136
+ } else if (type === 'Input.ChoiceSet' && Array.isArray(value)) {
137
+ // Multi-select choice sets use comma-separated values
138
+ element.value = value.join(',')
139
+ } else {
140
+ element.value = value
141
+ }
142
+ }
143
+
144
+ // Recursively process child elements
145
+ if (Array.isArray(element.body)) {
146
+ element.body.forEach((child: Record<string, unknown>) => processElement(child))
147
+ }
148
+ if (Array.isArray(element.items)) {
149
+ element.items.forEach((child: Record<string, unknown>) => processElement(child))
150
+ }
151
+ if (Array.isArray(element.columns)) {
152
+ element.columns.forEach((col: Record<string, unknown>) => {
153
+ if (Array.isArray(col.items)) {
154
+ col.items.forEach((child: Record<string, unknown>) => processElement(child))
155
+ }
156
+ })
157
+ }
158
+ if (Array.isArray(element.actions)) {
159
+ element.actions.forEach((action: Record<string, unknown>) => {
160
+ if (action.card) {
161
+ processElement(action.card as Record<string, unknown>)
162
+ }
163
+ })
164
+ }
165
+ }
166
+
167
+ processElement(payload)
168
+ return payload
169
+ }
170
+
171
+ /**
172
+ * Disable all interactive elements in the rendered card
173
+ * Used for displaying submitted cards in chat history
174
+ */
175
+ function disableInteractiveElements(container: HTMLElement): void {
176
+ const interactiveElements = container.querySelectorAll(
177
+ 'input, textarea, select, button'
178
+ )
179
+ interactiveElements.forEach((el) => {
180
+ el.setAttribute('disabled', 'true')
181
+ el.setAttribute('aria-disabled', 'true')
182
+ })
183
+
184
+ // Add a class to the container for additional styling hooks
185
+ container.classList.add('ac-readonly')
186
+ }
187
+
188
+ /**
189
+ * Render the adaptive card
190
+ */
191
+ function renderCard(): void {
192
+ if (!targetRef.value || !props.payload) {
193
+ return
194
+ }
195
+
196
+ // Create card instance if needed
197
+ if (!cardInstance.value) {
198
+ cardInstance.value = new MSAdaptiveCard()
199
+ }
200
+
201
+ // Apply host config if provided
202
+ if (props.hostConfig) {
203
+ cardInstance.value.hostConfig = new HostConfig(props.hostConfig)
204
+ }
205
+
206
+ try {
207
+ // Apply input data to payload if provided
208
+ const payloadToRender = props.inputData
209
+ ? applyInputData(props.payload, props.inputData)
210
+ : props.payload
211
+
212
+ // Parse and render the card
213
+ cardInstance.value.parse(payloadToRender)
214
+ const renderedCard = cardInstance.value.render()
215
+
216
+ if (renderedCard && targetRef.value) {
217
+ // Clear previous content and append new
218
+ targetRef.value.innerHTML = ''
219
+ targetRef.value.appendChild(renderedCard)
220
+
221
+ // Accessibility: Add aria-level to heading elements without it
222
+ const headings = targetRef.value.querySelectorAll("[role='heading']")
223
+ headings.forEach((heading) => {
224
+ if (heading.getAttribute('aria-level') === null) {
225
+ heading.setAttribute('aria-level', '4')
226
+ }
227
+ })
228
+
229
+ // Disable all interactive elements when in readonly mode
230
+ if (props.readonly) {
231
+ disableInteractiveElements(targetRef.value)
232
+ }
233
+ }
234
+ } catch (error) {
235
+ console.error('AdaptiveCardRenderer: Unable to render Adaptive Card', {
236
+ error,
237
+ payload: props.payload,
238
+ })
239
+ }
240
+ }
241
+
242
+ // Render on mount (markdown processing is set up at module scope)
243
+ onMounted(() => {
244
+ renderCard()
245
+ })
246
+
247
+ // Re-render when payload, hostConfig, or inputData changes
248
+ watch(
249
+ () => [props.payload, props.hostConfig, props.inputData],
250
+ () => {
251
+ renderCard()
252
+ },
253
+ { deep: true }
254
+ )
255
+
256
+ // Cleanup on unmount
257
+ onBeforeUnmount(() => {
258
+ cardInstance.value = null
259
+ })
260
+ </script>
@@ -44,35 +44,22 @@ export interface UseCollationReturn {
44
44
  }
45
45
 
46
46
  /**
47
- * Check if two messages can be collated together
47
+ * Check if two messages can be collated together.
48
+ * Both must be bot messages with text, no rich content, attachments, or plugins.
48
49
  */
49
50
  function canCollate(current: IMessage, previous: IMessage): boolean {
50
- // Must both be bot messages
51
- if (current.source !== 'bot' || previous.source !== 'bot') {
52
- return false
53
- }
54
-
55
- // Must both be simple text messages (no rich content)
56
- if (current.data?._cognigy?._webchat || previous.data?._cognigy?._webchat) {
57
- return false
58
- }
59
-
60
- // Must both have text
61
- if (!current.text || !previous.text) {
62
- return false
63
- }
64
-
65
- // Don't collate if either has attachments
66
- if (current.data?.attachments || previous.data?.attachments) {
67
- return false
68
- }
69
-
70
- // Don't collate plugin messages
71
- if (current.data?._plugin || previous.data?._plugin) {
72
- return false
73
- }
74
-
75
- return true
51
+ return (
52
+ current.source === 'bot' &&
53
+ previous.source === 'bot' &&
54
+ Boolean(current.text) &&
55
+ Boolean(previous.text) &&
56
+ !current.data?._cognigy?._webchat &&
57
+ !previous.data?._cognigy?._webchat &&
58
+ !current.data?.attachments &&
59
+ !previous.data?.attachments &&
60
+ !current.data?._plugin &&
61
+ !previous.data?._plugin
62
+ )
76
63
  }
77
64
 
78
65
  /**
@@ -109,37 +96,33 @@ export function useCollation(
109
96
 
110
97
  const result: CollatedMessage[] = []
111
98
 
112
- for (let i = 0; i < msgs.length; i++) {
113
- const current = msgs[i]
99
+ for (const current of msgs) {
100
+ const lastIndex = result.length - 1
101
+ const lastCollated = result[lastIndex]
114
102
 
115
- // Check if we can collate with the last message in result
116
- if (result.length > 0) {
117
- const lastCollated = result[result.length - 1]
118
- const lastOriginal = lastCollated.collatedFrom
119
- ? lastCollated.collatedFrom[lastCollated.collatedFrom.length - 1]
103
+ if (lastCollated) {
104
+ const existingCollatedFrom = lastCollated.collatedFrom
105
+ const lastOriginal = existingCollatedFrom
106
+ ? existingCollatedFrom[existingCollatedFrom.length - 1]
120
107
  : lastCollated
121
108
 
122
109
  if (canCollate(current, lastOriginal)) {
123
- // Collate: combine texts with newline separator
124
- const existingText = lastCollated.text ?? ''
125
- const currentText = current.text ?? ''
126
-
127
- const collatedFrom = lastCollated.collatedFrom
128
- ? [...lastCollated.collatedFrom, current]
110
+ // Collate: create new object with combined text (avoid mutating existing)
111
+ const collatedFrom = existingCollatedFrom
112
+ ? [...existingCollatedFrom, current]
129
113
  : [lastOriginal, current]
130
114
 
131
- // Update the last message with combined text (joined by newline)
132
- result[result.length - 1] = {
115
+ result[lastIndex] = {
133
116
  ...lastCollated,
134
- text: existingText + '\n' + currentText,
117
+ text: (lastCollated.text ?? '') + '\n' + (current.text ?? ''),
135
118
  collatedFrom,
136
119
  }
137
120
  continue
138
121
  }
139
122
  }
140
123
 
141
- // Can't collate, add as new message
142
- result.push(current as CollatedMessage)
124
+ // Can't collate, add as new message (shallow copy to avoid mutating original)
125
+ result.push({ ...current })
143
126
  }
144
127
 
145
128
  return result
@@ -22,7 +22,7 @@ export interface IMessageWithId extends IMessage {
22
22
  * Type guard to check if a message has an id property
23
23
  */
24
24
  export function hasMessageId(message: IMessage): message is IMessageWithId & { id: string } {
25
- return 'id' in message && typeof (message as IMessageWithId).id === 'string'
25
+ return 'id' in message && typeof message.id === 'string'
26
26
  }
27
27
 
28
28
  /**
@@ -120,6 +120,12 @@ export interface ChatSettings {
120
120
 
121
121
  // Link color
122
122
  textLinkColor?: string
123
+
124
+ // Adaptive Card colors
125
+ adaptiveCardTextColor?: string
126
+ adaptiveCardInputColor?: string
127
+ adaptiveCardInputBackground?: string
128
+ adaptiveCardInputBorder?: string
123
129
  }
124
130
  behavior?: {
125
131
  renderMarkdown?: boolean
@@ -127,6 +133,15 @@ export interface ChatSettings {
127
133
  messageDelay?: number
128
134
  collateStreamedOutputs?: boolean
129
135
  focusInputAfterPostback?: boolean
136
+ /**
137
+ * Controls adaptive card interactivity.
138
+ * - `true`: All cards are readonly (presentation only), regardless of submitted data
139
+ * - `false` or `undefined`: Smart default - readonly only if card has submitted data
140
+ *
141
+ * Use `true` for chat history/transcript displays where no interaction is needed.
142
+ * Use `false`/omit for interactive chat interfaces with smart auto-detection.
143
+ */
144
+ adaptiveCardsReadonly?: boolean
130
145
  }
131
146
  widgetSettings?: {
132
147
  enableDefaultPreview?: boolean
@@ -369,5 +384,44 @@ export interface IWebchatChannelPayload {
369
384
  quick_replies?: QuickReply[]
370
385
  attachment?: TemplateAttachment | AudioAttachment | ImageAttachment | VideoAttachment
371
386
  }
372
- adaptiveCard?: unknown
387
+ adaptiveCard?: Record<string, unknown>
388
+ /** Submitted adaptive card data */
389
+ adaptiveCardData?: Record<string, unknown>
390
+ data?: Record<string, unknown>
391
+ formData?: Record<string, unknown>
392
+ }
393
+
394
+ /**
395
+ * Cognigy message data structure
396
+ * Shape of message.data._cognigy
397
+ */
398
+ export interface ICognigyData {
399
+ _webchat?: IWebchatChannelPayload
400
+ _defaultPreview?: IWebchatChannelPayload
401
+ _facebook?: IWebchatChannelPayload
402
+ }
403
+
404
+ /**
405
+ * Plugin payload structure for custom message types
406
+ */
407
+ export interface IPluginPayload {
408
+ type?: string
409
+ payload?: Record<string, unknown>
410
+ }
411
+
412
+ /**
413
+ * Extended message data with Cognigy-specific fields
414
+ * Use this when accessing message.data with Cognigy payload structures
415
+ */
416
+ export interface IMessageDataExtended {
417
+ _cognigy?: ICognigyData
418
+ _plugin?: IPluginPayload
419
+ /** Adaptive card submission request data */
420
+ request?: {
421
+ value?: Record<string, unknown>
422
+ }
423
+ /** Direct adaptive card data fields (fallback locations) */
424
+ adaptiveCardData?: Record<string, unknown>
425
+ data?: Record<string, unknown>
426
+ formData?: Record<string, unknown>
373
427
  }
@@ -4,6 +4,13 @@
4
4
 
5
5
  import type {IWebchatButton, IWebchatQuickReply} from '../types'
6
6
 
7
+ // Pre-compiled regex patterns (compiled once at module load)
8
+ const TEMPLATE_PLACEHOLDER_REGEX = /{(\w+)}/g
9
+ const URL_MATCHER_REGEX =
10
+ /(^|\s)(\b(https?):\/\/([-A-Z0-9+&@$#/%?=~_|!:,.;\p{L}]*[-A-Z0-9+&$@#/%=~_|\p{L}]))/giu
11
+ const CONTROL_CHARS_REGEX = /[\r\n\f]/g
12
+ const URL_SCHEME_REGEX = /^[a-zA-Z][a-zA-Z0-9+.-]*:/
13
+
7
14
  /**
8
15
  * Gets the label for a button
9
16
  * Returns "Call" for phone_number buttons without title
@@ -29,7 +36,7 @@ export function interpolateString(
29
36
  template: string,
30
37
  replacements: Record<string, string>
31
38
  ): string {
32
- return template.replace(/{(\w+)}/g, (_, key) => {
39
+ return template.replace(TEMPLATE_PLACEHOLDER_REGEX, (_, key) => {
33
40
  return key in replacements ? replacements[key] : ''
34
41
  })
35
42
  }
@@ -55,19 +62,31 @@ export function moveFocusToMessageFocusTarget(dataMessageId: string): void {
55
62
  }, 0)
56
63
  }
57
64
 
65
+ /**
66
+ * Escapes HTML special characters to prevent injection attacks
67
+ */
68
+ function escapeHtmlAttribute(str: string): string {
69
+ return str
70
+ .replace(/&/g, '&amp;')
71
+ .replace(/"/g, '&quot;')
72
+ .replace(/'/g, '&#39;')
73
+ .replace(/</g, '&lt;')
74
+ .replace(/>/g, '&gt;')
75
+ }
76
+
58
77
  /**
59
78
  * Helper function that replaces URLs in a string with HTML anchor elements
60
79
  * - Works with URLs starting with http/https, www., or just domain/subdomain
61
80
  * - Will only match URLs at the beginning or following whitespace
62
81
  * - Will not work with emails
82
+ * - URLs are escaped to prevent injection attacks
63
83
  */
64
84
  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>`
85
+ return text.replace(URL_MATCHER_REGEX, (_, prefix, url) => {
86
+ // Escape URL for safe insertion into HTML attributes and content
87
+ const escapedUrl = escapeHtmlAttribute(url)
88
+ // Preserve leading whitespace, but keep it outside the anchor
89
+ return `${prefix}<a href="${escapedUrl}" target="_blank">${escapedUrl}</a>`
71
90
  })
72
91
  }
73
92
 
@@ -79,13 +98,13 @@ export function getBackgroundImage(url: string): string | undefined {
79
98
  if (!url) return undefined
80
99
 
81
100
  // Remove control characters that could break CSS parsing
82
- let sanitized = url.replace(/[\r\n\f]/g, '')
101
+ let sanitized = url.replace(CONTROL_CHARS_REGEX, '')
83
102
 
84
103
  // 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)) {
104
+ if (URL_SCHEME_REGEX.test(sanitized)) {
86
105
  try {
87
106
  const parsed = new URL(sanitized)
88
- if (!/^https?:$/i.test(parsed.protocol)) {
107
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
89
108
  return undefined
90
109
  }
91
110
  // Normalize absolute URLs
@@ -117,13 +136,12 @@ const ONE_KB = 1000
117
136
  * Example: "document.pdf" → "document."
118
137
  */
119
138
  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
139
+ const lastDotIndex = fileNameWithExtension.lastIndexOf('.')
140
+ if (lastDotIndex > 0) {
141
+ return fileNameWithExtension.slice(0, lastDotIndex + 1)
126
142
  }
143
+ // Return full name if no extension
144
+ return fileNameWithExtension
127
145
  }
128
146
 
129
147
  /**
@@ -131,12 +149,11 @@ export function getFileName(fileNameWithExtension: string): string {
131
149
  * Example: "document.pdf" → "pdf"
132
150
  */
133
151
  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
152
+ const lastDotIndex = fileNameWithExtension.lastIndexOf('.')
153
+ if (lastDotIndex > 0 && lastDotIndex < fileNameWithExtension.length - 1) {
154
+ return fileNameWithExtension.slice(lastDotIndex + 1)
139
155
  }
156
+ return null
140
157
  }
141
158
 
142
159
  /**
@@ -152,13 +169,18 @@ export function getSizeLabel(size: number): string {
152
169
  }
153
170
 
154
171
  /**
155
- * Valid image MIME types for file attachments
172
+ * Valid image MIME types for file attachments (Set for O(1) lookup)
156
173
  */
157
- export const VALID_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']
174
+ export const VALID_IMAGE_MIME_TYPES = new Set([
175
+ 'image/jpeg',
176
+ 'image/png',
177
+ 'image/gif',
178
+ 'image/webp',
179
+ ])
158
180
 
159
181
  /**
160
182
  * Checks if attachment is a valid image type
161
183
  */
162
184
  export function isImageAttachment(mimeType: string): boolean {
163
- return VALID_IMAGE_MIME_TYPES.includes(mimeType)
185
+ return VALID_IMAGE_MIME_TYPES.has(mimeType)
164
186
  }
@@ -10,6 +10,9 @@ import type { IMessage } from '@cognigy/socket-client'
10
10
  import type { ChatConfig, MatchRule, MessagePlugin, MatchResult } from '../types'
11
11
  import { isAdaptiveCardPayload } from '../types'
12
12
 
13
+ // Pre-compiled regex for escape sequence check
14
+ const ESCAPE_SEQUENCE_REGEX = /^[\n\t\r\v\f\s]*$/
15
+
13
16
  /**
14
17
  * Check if message has channel payload
15
18
  */
@@ -46,16 +49,17 @@ function isOnlyEscapeSequence(text: string | null | undefined): boolean {
46
49
  }
47
50
 
48
51
  const trimmed = text.trim()
49
- return trimmed === '' || /^[\n\t\r\v\f\s]*$/.test(trimmed)
52
+ return trimmed === '' || ESCAPE_SEQUENCE_REGEX.test(trimmed)
50
53
  }
51
54
 
52
55
  /**
53
56
  * Default match rules for internal message types.
54
57
  * These rules map message data structures to component names.
55
58
  * Components are resolved by name lookup in Message.vue.
59
+ *
60
+ * Created once at module load for performance - rules are static.
56
61
  */
57
- export function createDefaultMatchRules(): MatchRule[] {
58
- return [
62
+ const DEFAULT_MATCH_RULES: MatchRule[] = [
59
63
  // xApp submit
60
64
  {
61
65
  name: 'XAppSubmit',
@@ -240,6 +244,14 @@ export function createDefaultMatchRules(): MatchRule[] {
240
244
  },
241
245
  },
242
246
  ]
247
+
248
+ /**
249
+ * Returns a copy of the default match rules.
250
+ * Exposed for testing and extension purposes.
251
+ * Returns a new array to prevent mutation of internal rules.
252
+ */
253
+ export function createDefaultMatchRules(): MatchRule[] {
254
+ return [...DEFAULT_MATCH_RULES]
243
255
  }
244
256
 
245
257
  /**
@@ -254,9 +266,11 @@ export function match(
254
266
  config?: ChatConfig,
255
267
  externalPlugins: MessagePlugin[] = []
256
268
  ): MatchResult[] {
257
- // Combine external plugins with default rules
258
- // External plugins are checked first
259
- const allRules: MatchResult[] = [...externalPlugins, ...createDefaultMatchRules()]
269
+ // External plugins are checked first, then default rules
270
+ // Always create a new array to prevent mutation of DEFAULT_MATCH_RULES
271
+ const allRules: MatchResult[] = externalPlugins.length > 0
272
+ ? [...externalPlugins, ...DEFAULT_MATCH_RULES]
273
+ : [...DEFAULT_MATCH_RULES]
260
274
 
261
275
  const matchedRules: MatchResult[] = []
262
276
 
@@ -95,8 +95,7 @@ export function sanitizeHTMLWithConfig(
95
95
  })
96
96
 
97
97
  try {
98
- const sanitized = DOMPurify.sanitize(text, config).toString()
99
- return sanitized
98
+ return DOMPurify.sanitize(text, config).toString()
100
99
  } catch (error) {
101
100
  console.error('sanitizeHTMLWithConfig: Sanitization failed', {
102
101
  error,
@@ -49,6 +49,12 @@ export function configColorsToCssVariables(
49
49
 
50
50
  // Link color
51
51
  '--cc-text-link-color': colors.textLinkColor,
52
+
53
+ // Adaptive Card colors
54
+ '--cc-adaptive-card-text-color': colors.adaptiveCardTextColor,
55
+ '--cc-adaptive-card-input-color': colors.adaptiveCardInputColor,
56
+ '--cc-adaptive-card-input-background': colors.adaptiveCardInputBackground,
57
+ '--cc-adaptive-card-input-border': colors.adaptiveCardInputBorder,
52
58
  }
53
59
 
54
60
  // Filter out undefined values and return only defined CSS variables