Skip to content

Commit cfc5f64

Browse files
committed
✨ Automatically parse markdown from variables in text bubbles
Closes #539
1 parent 9e6a1f7 commit cfc5f64

File tree

12 files changed

+1191
-127
lines changed

12 files changed

+1191
-127
lines changed

packages/bot-engine/executeGroup.ts

+96-6
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from '@typebot.io/schemas'
1212
import {
1313
isBubbleBlock,
14+
isEmpty,
1415
isInputBlock,
1516
isIntegrationBlock,
1617
isLogicBlock,
@@ -26,6 +27,12 @@ import { getPrefilledInputValue } from './getPrefilledValue'
2627
import { parseDateInput } from './blocks/inputs/date/parseDateInput'
2728
import { deepParseVariables } from './variables/deepParseVariables'
2829
import { parseVideoUrl } from '@typebot.io/lib/parseVideoUrl'
30+
import { TDescendant, createPlateEditor } from '@udecode/plate-common'
31+
import {
32+
createDeserializeMdPlugin,
33+
deserializeMd,
34+
} from '@udecode/plate-serializer-md'
35+
import { getVariablesToParseInfoInText } from './variables/parseVariables'
2936

3037
export const executeGroup =
3138
(
@@ -158,12 +165,19 @@ const parseBubbleBlock =
158165
(variables: Variable[]) =>
159166
(block: BubbleBlock): ChatReply['messages'][0] => {
160167
switch (block.type) {
161-
case BubbleBlockType.TEXT:
162-
return deepParseVariables(
163-
variables,
164-
{},
165-
{ takeLatestIfList: true }
166-
)(block)
168+
case BubbleBlockType.TEXT: {
169+
return {
170+
...block,
171+
content: {
172+
...block.content,
173+
richText: parseVariablesInRichText(
174+
block.content.richText,
175+
variables
176+
),
177+
},
178+
}
179+
}
180+
167181
case BubbleBlockType.EMBED: {
168182
const message = deepParseVariables(variables)(block)
169183
return {
@@ -189,6 +203,82 @@ const parseBubbleBlock =
189203
}
190204
}
191205

206+
const parseVariablesInRichText = (
207+
elements: TDescendant[],
208+
variables: Variable[]
209+
): TDescendant[] => {
210+
const parsedElements: TDescendant[] = []
211+
for (const element of elements) {
212+
if ('text' in element) {
213+
const text = element.text as string
214+
if (isEmpty(text)) {
215+
parsedElements.push(element)
216+
continue
217+
}
218+
const variablesInText = getVariablesToParseInfoInText(text, variables)
219+
if (variablesInText.length === 0) {
220+
parsedElements.push(element)
221+
continue
222+
}
223+
for (const variableInText of variablesInText) {
224+
const textBeforeVariable = text.substring(0, variableInText.startIndex)
225+
const textAfterVariable = text.substring(variableInText.endIndex)
226+
const isStandaloneElement =
227+
isEmpty(textBeforeVariable) && isEmpty(textAfterVariable)
228+
const variableElements = convertMarkdownToRichText(
229+
isStandaloneElement
230+
? variableInText.value
231+
: variableInText.value.replace(/[\n]+/g, ' ')
232+
)
233+
if (isStandaloneElement) {
234+
parsedElements.push(...variableElements)
235+
continue
236+
}
237+
const children: TDescendant[] = []
238+
if (isNotEmpty(textBeforeVariable))
239+
children.push({
240+
text: textBeforeVariable,
241+
})
242+
children.push({
243+
type: 'inline-variable',
244+
children: variableElements,
245+
})
246+
if (isNotEmpty(textAfterVariable))
247+
children.push({
248+
...element,
249+
text: textAfterVariable,
250+
})
251+
parsedElements.push(...children)
252+
}
253+
continue
254+
}
255+
256+
const type =
257+
element.children.length === 1 &&
258+
'text' in element.children[0] &&
259+
(element.children[0].text as string).startsWith('{{') &&
260+
(element.children[0].text as string).endsWith('}}')
261+
? 'variable'
262+
: element.type
263+
264+
parsedElements.push({
265+
...element,
266+
type,
267+
children: parseVariablesInRichText(
268+
element.children as TDescendant[],
269+
variables
270+
),
271+
})
272+
}
273+
return parsedElements
274+
}
275+
276+
const convertMarkdownToRichText = (text: string): TDescendant[] => {
277+
const plugins = [createDeserializeMdPlugin()]
278+
//@ts-ignore
279+
return deserializeMd(createPlateEditor({ plugins }), text)
280+
}
281+
192282
export const parseInput =
193283
(state: SessionState) =>
194284
async (block: InputBlock): Promise<ChatReply['input']> => {

packages/bot-engine/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"@typebot.io/schemas": "workspace:*",
1818
"@typebot.io/tsconfig": "workspace:*",
1919
"@udecode/plate-common": "^21.1.5",
20+
"@udecode/plate-serializer-md": "^24.4.0",
2021
"ai": "2.1.32",
2122
"chrono-node": "2.6.6",
2223
"date-fns": "^2.30.0",

packages/bot-engine/variables/parseVariables.ts

+29
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,35 @@ export const parseVariables =
5555
)
5656
}
5757

58+
type VariableToParseInformation = {
59+
startIndex: number
60+
endIndex: number
61+
textToReplace: string
62+
value: string
63+
}
64+
65+
export const getVariablesToParseInfoInText = (
66+
text: string,
67+
variables: Variable[]
68+
): VariableToParseInformation[] => {
69+
const pattern = /\{\{([^{}]+)\}\}|(\$)\{\{([^{}]+)\}\}/g
70+
const variablesToParseInfo: VariableToParseInformation[] = []
71+
let match
72+
while ((match = pattern.exec(text)) !== null) {
73+
const matchedVarName = match[1] ?? match[3]
74+
const variable = variables.find((variable) => {
75+
return matchedVarName === variable.name && isDefined(variable.value)
76+
}) as VariableWithValue | undefined
77+
variablesToParseInfo.push({
78+
startIndex: match.index,
79+
endIndex: match.index + match[0].length,
80+
textToReplace: match[0],
81+
value: safeStringify(variable?.value) ?? '',
82+
})
83+
}
84+
return variablesToParseInfo
85+
}
86+
5887
const parseVariableValueInJson = (value: VariableWithValue['value']) => {
5988
const stringifiedValue = JSON.stringify(value)
6089
if (typeof value === 'string') return stringifiedValue.slice(1, -1)

packages/embeds/js/package.json

+6-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@typebot.io/js",
3-
"version": "0.1.33",
3+
"version": "0.1.34",
44
"description": "Javascript library to display typebots on your website",
55
"type": "module",
66
"main": "dist/index.js",
@@ -14,7 +14,9 @@
1414
"dependencies": {
1515
"@stripe/stripe-js": "1.54.1",
1616
"@udecode/plate-common": "^21.1.5",
17+
"dompurify": "^3.0.6",
1718
"eventsource-parser": "^1.0.0",
19+
"marked": "^9.0.3",
1820
"solid-element": "1.7.1",
1921
"solid-js": "1.7.8"
2022
},
@@ -24,11 +26,12 @@
2426
"@rollup/plugin-node-resolve": "15.1.0",
2527
"@rollup/plugin-terser": "0.4.3",
2628
"@rollup/plugin-typescript": "11.1.2",
27-
"@typebot.io/lib": "workspace:*",
29+
"@typebot.io/bot-engine": "workspace:*",
2830
"@typebot.io/env": "workspace:*",
31+
"@typebot.io/lib": "workspace:*",
2932
"@typebot.io/schemas": "workspace:*",
3033
"@typebot.io/tsconfig": "workspace:*",
31-
"@typebot.io/bot-engine": "workspace:*",
34+
"@types/dompurify": "^3.0.3",
3235
"autoprefixer": "10.4.14",
3336
"babel-preset-solid": "1.7.7",
3437
"clsx": "2.0.0",

packages/embeds/js/src/assets/index.css

+1-5
Original file line numberDiff line numberDiff line change
@@ -94,14 +94,10 @@ textarea {
9494
font-weight: 300;
9595
}
9696

97-
.slate-a {
97+
a {
9898
text-decoration: underline;
9999
}
100100

101-
.slate-html-container > div {
102-
min-height: 24px;
103-
}
104-
105101
.slate-bold {
106102
font-weight: bold;
107103
}

packages/embeds/js/src/components/bubbles/StreamingBubble.tsx

+17-7
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,32 @@
11
import { streamingMessage } from '@/utils/streamingMessageSignal'
22
import { createEffect, createSignal } from 'solid-js'
3+
import { marked } from 'marked'
4+
import domPurify from 'dompurify'
35

46
type Props = {
57
streamingMessageId: string
68
}
79

10+
marked.use({
11+
renderer: {
12+
link: (href, _title, text) => {
13+
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${text}</a>`
14+
},
15+
},
16+
})
17+
818
export const StreamingBubble = (props: Props) => {
9-
let ref: HTMLDivElement | undefined
1019
const [content, setContent] = createSignal<string>('')
1120

1221
createEffect(() => {
1322
if (streamingMessage()?.id === props.streamingMessageId)
14-
setContent(streamingMessage()?.content ?? '')
23+
setContent(
24+
domPurify.sanitize(marked.parse(streamingMessage()?.content ?? ''))
25+
)
1526
})
1627

1728
return (
18-
<div class="flex flex-col animate-fade-in" ref={ref}>
29+
<div class="flex flex-col animate-fade-in">
1930
<div class="flex w-full items-center">
2031
<div class="flex relative items-start typebot-host-bubble">
2132
<div
@@ -28,11 +39,10 @@ export const StreamingBubble = (props: Props) => {
2839
/>
2940
<div
3041
class={
31-
'overflow-hidden text-fade-in mx-4 my-2 whitespace-pre-wrap slate-html-container relative text-ellipsis opacity-100 h-full'
42+
'flex flex-col overflow-hidden text-fade-in mx-4 my-2 relative text-ellipsis h-full gap-6'
3243
}
33-
>
34-
{content()}
35-
</div>
44+
innerHTML={content()}
45+
/>
3646
</div>
3747
</div>
3848
</div>

packages/embeds/js/src/features/blocks/bubbles/textBubble/components/TextBubble.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { TypingBubble } from '@/components'
22
import type { TextBubbleContent, TypingEmulation } from '@typebot.io/schemas'
33
import { For, createSignal, onCleanup, onMount } from 'solid-js'
4-
import { PlateBlock } from './plate/PlateBlock'
4+
import { PlateElement } from './plate/PlateBlock'
55
import { computePlainText } from '../helpers/convertRichTextToPlainText'
66
import { clsx } from 'clsx'
77
import { isMobile } from '@/utils/isMobileSignal'
@@ -70,7 +70,7 @@ export const TextBubble = (props: Props) => {
7070
}}
7171
>
7272
<For each={props.content.richText}>
73-
{(element) => <PlateBlock element={element} />}
73+
{(element) => <PlateElement element={element} />}
7474
</For>
7575
</div>
7676
</div>

0 commit comments

Comments
 (0)