@sales-bot-llm/sdk 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.
- package/biome.json +36 -0
- package/docs/superpowers/plans/2026-05-08-sales-bot-sdk-plan.md +258 -0
- package/docs/superpowers/plans/2026-05-11-w3-sales-tool-polish-plan.md +476 -0
- package/docs/superpowers/specs/2026-05-08-sales-bot-sdk-design.md +587 -0
- package/example/.env.example +5 -0
- package/example/README.md +90 -0
- package/example/index.html +12 -0
- package/example/package.json +27 -0
- package/example/public/vanilla.global.js +345 -0
- package/example/src/App.tsx +50 -0
- package/example/src/main.tsx +16 -0
- package/example/src/routes/HookDemo.tsx +174 -0
- package/example/src/routes/VanillaDemo.tsx +67 -0
- package/example/src/routes/WidgetDemo.tsx +55 -0
- package/example/src/styles.css +18 -0
- package/example/tsconfig.json +19 -0
- package/example/tsconfig.tsbuildinfo +1 -0
- package/example/vite.config.ts +4 -0
- package/package.json +106 -0
- package/pnpm-workspace.yaml +3 -0
- package/src/core/client.ts +245 -0
- package/src/core/conversation.ts +34 -0
- package/src/core/index.ts +6 -0
- package/src/core/sse-parser.ts +87 -0
- package/src/core/storage.ts +72 -0
- package/src/core/transport.ts +271 -0
- package/src/core/types.ts +314 -0
- package/src/core/visitor.ts +21 -0
- package/src/react/index.ts +2 -0
- package/src/react/use-sales-bot.tsx +182 -0
- package/src/vanilla/index.ts +38 -0
- package/src/vue/index.ts +2 -0
- package/src/vue/use-sales-bot.ts +152 -0
- package/src/widget/index.ts +3 -0
- package/src/widget/markdown.ts +69 -0
- package/src/widget/styles.ts +350 -0
- package/src/widget/widget.ts +442 -0
- package/tests/contract/wire-format.test.ts +158 -0
- package/tests/core/client.test.ts +292 -0
- package/tests/core/conversation.test.ts +41 -0
- package/tests/core/sse-parser.test.ts +142 -0
- package/tests/core/storage.test.ts +78 -0
- package/tests/core/transport.test.ts +204 -0
- package/tests/core/visitor.test.ts +42 -0
- package/tests/react/use-sales-bot.test.tsx +188 -0
- package/tests/sales-tool-discriminator.test.ts +45 -0
- package/tests/setup.ts +3 -0
- package/tests/vanilla/vanilla.test.ts +37 -0
- package/tests/vue/use-sales-bot.test.ts +163 -0
- package/tests/widget/markdown.test.ts +113 -0
- package/tests/widget/widget.test.ts +388 -0
- package/tsconfig.json +28 -0
- package/tsup.config.ts +38 -0
- package/vitest.config.ts +26 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
4
|
+
import { SalesBotClient } from '../core/client'
|
|
5
|
+
import { SalesBotError, isSalesWorkflowTool } from '../core/types'
|
|
6
|
+
import type {
|
|
7
|
+
SalesBotClientOptions,
|
|
8
|
+
AskOptions,
|
|
9
|
+
Message,
|
|
10
|
+
DeltaEvent,
|
|
11
|
+
MessageCompleteEvent,
|
|
12
|
+
TurnStartedEvent,
|
|
13
|
+
ToolCallStartedEvent,
|
|
14
|
+
} from '../core/types'
|
|
15
|
+
|
|
16
|
+
export interface UseSalesBotOptions extends SalesBotClientOptions {
|
|
17
|
+
/** Optional initial conversationId (e.g. restored from URL hash) */
|
|
18
|
+
conversationId?: string
|
|
19
|
+
/**
|
|
20
|
+
* Fires when a sales-workflow tool call starts (e.g. quotes__send_as_pdf).
|
|
21
|
+
* Convenience over manually filtering tool_call_started events via
|
|
22
|
+
* isSalesWorkflowTool. Pass-through; receives the same payload.
|
|
23
|
+
*/
|
|
24
|
+
onSalesToolCall?: (event: ToolCallStartedEvent) => void
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface UseSalesBotResult {
|
|
28
|
+
ask: (message: string, opts?: AskOptions) => Promise<void>
|
|
29
|
+
messages: Message[]
|
|
30
|
+
isStreaming: boolean
|
|
31
|
+
error: SalesBotError | null
|
|
32
|
+
conversationId: string | null
|
|
33
|
+
visitorToken: string
|
|
34
|
+
reset: () => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let msgCounter = 0
|
|
38
|
+
function nextId(): string {
|
|
39
|
+
return `msg-${++msgCounter}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function useSalesBot(opts: UseSalesBotOptions): UseSalesBotResult {
|
|
43
|
+
// Stable client reference — never re-created on re-render
|
|
44
|
+
const clientRef = useRef<SalesBotClient | null>(null)
|
|
45
|
+
if (clientRef.current === null) {
|
|
46
|
+
clientRef.current = new SalesBotClient(opts)
|
|
47
|
+
if (opts.conversationId) {
|
|
48
|
+
clientRef.current.setConversationId(opts.conversationId)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const client = clientRef.current
|
|
52
|
+
|
|
53
|
+
const [messages, setMessages] = useState<Message[]>([])
|
|
54
|
+
const [isStreaming, setIsStreaming] = useState(false)
|
|
55
|
+
const [error, setError] = useState<SalesBotError | null>(null)
|
|
56
|
+
const [conversationId, setConversationId] = useState<string | null>(
|
|
57
|
+
opts.conversationId ?? null,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
// Keep latest onSalesToolCall in a ref so the event bus subscription can
|
|
61
|
+
// call the current handler without resubscribing on every render.
|
|
62
|
+
const onSalesToolCallRef = useRef<UseSalesBotOptions['onSalesToolCall']>(
|
|
63
|
+
opts.onSalesToolCall,
|
|
64
|
+
)
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
onSalesToolCallRef.current = opts.onSalesToolCall
|
|
67
|
+
}, [opts.onSalesToolCall])
|
|
68
|
+
|
|
69
|
+
// Subscribe once for the client's lifetime; the ref above means we don't
|
|
70
|
+
// tear down + re-subscribe whenever a caller passes an inline callback.
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const off = client.on('tool_call_started', (e: ToolCallStartedEvent) => {
|
|
73
|
+
const cb = onSalesToolCallRef.current
|
|
74
|
+
if (!cb) return
|
|
75
|
+
if (isSalesWorkflowTool(e.name)) {
|
|
76
|
+
cb(e)
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
return () => {
|
|
80
|
+
off()
|
|
81
|
+
}
|
|
82
|
+
}, [client])
|
|
83
|
+
|
|
84
|
+
const ask = useCallback(
|
|
85
|
+
async (message: string, askOpts?: AskOptions): Promise<void> => {
|
|
86
|
+
setError(null)
|
|
87
|
+
setIsStreaming(true)
|
|
88
|
+
|
|
89
|
+
// Append user message immediately
|
|
90
|
+
const userMsgId = nextId()
|
|
91
|
+
setMessages((prev) => [
|
|
92
|
+
...prev,
|
|
93
|
+
{ id: userMsgId, role: 'user', content: message },
|
|
94
|
+
])
|
|
95
|
+
|
|
96
|
+
// Create a placeholder for the streaming assistant message
|
|
97
|
+
const assistantMsgId = nextId()
|
|
98
|
+
let assistantContent = ''
|
|
99
|
+
let assistantAdded = false
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
for await (const event of client.ask(message, askOpts)) {
|
|
103
|
+
switch (event.event) {
|
|
104
|
+
case 'turn_started': {
|
|
105
|
+
const data = event.data as TurnStartedEvent
|
|
106
|
+
setConversationId(data.conversationId)
|
|
107
|
+
break
|
|
108
|
+
}
|
|
109
|
+
case 'delta': {
|
|
110
|
+
const data = event.data as DeltaEvent
|
|
111
|
+
assistantContent += data.content
|
|
112
|
+
if (!assistantAdded) {
|
|
113
|
+
assistantAdded = true
|
|
114
|
+
setMessages((prev) => [
|
|
115
|
+
...prev,
|
|
116
|
+
{ id: assistantMsgId, role: 'assistant', content: assistantContent, streaming: true },
|
|
117
|
+
])
|
|
118
|
+
} else {
|
|
119
|
+
setMessages((prev) =>
|
|
120
|
+
prev.map((m) =>
|
|
121
|
+
m.id === assistantMsgId
|
|
122
|
+
? { ...m, content: assistantContent }
|
|
123
|
+
: m,
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
break
|
|
128
|
+
}
|
|
129
|
+
case 'message_complete': {
|
|
130
|
+
const data = event.data as MessageCompleteEvent
|
|
131
|
+
// Replace with the canonical content from the backend
|
|
132
|
+
setMessages((prev) =>
|
|
133
|
+
prev.map((m) =>
|
|
134
|
+
m.id === assistantMsgId
|
|
135
|
+
? { ...m, content: data.content, streaming: false }
|
|
136
|
+
: m,
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
break
|
|
140
|
+
}
|
|
141
|
+
case 'done':
|
|
142
|
+
setIsStreaming(false)
|
|
143
|
+
break
|
|
144
|
+
case 'error':
|
|
145
|
+
// Will be caught below if it throws; otherwise set error state
|
|
146
|
+
break
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
} catch (err) {
|
|
150
|
+
const sbErr =
|
|
151
|
+
err instanceof SalesBotError
|
|
152
|
+
? err
|
|
153
|
+
: new SalesBotError({ code: 'internal', message: String(err), retryable: false })
|
|
154
|
+
setError(sbErr)
|
|
155
|
+
// Remove the incomplete assistant message if nothing was streamed
|
|
156
|
+
if (!assistantAdded) {
|
|
157
|
+
setMessages((prev) => prev.filter((m) => m.id !== assistantMsgId))
|
|
158
|
+
}
|
|
159
|
+
} finally {
|
|
160
|
+
setIsStreaming(false)
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
[client],
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
const reset = useCallback(() => {
|
|
167
|
+
setMessages([])
|
|
168
|
+
setError(null)
|
|
169
|
+
setConversationId(null)
|
|
170
|
+
client.setConversationId(null)
|
|
171
|
+
}, [client])
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
ask,
|
|
175
|
+
messages,
|
|
176
|
+
isStreaming,
|
|
177
|
+
error,
|
|
178
|
+
conversationId,
|
|
179
|
+
visitorToken: client.getVisitorToken(),
|
|
180
|
+
reset,
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vanilla / IIFE entry point.
|
|
3
|
+
* Bundled by tsup as an IIFE exposing `window.SalesBot = { init, widget }`.
|
|
4
|
+
*
|
|
5
|
+
* Usage (plain HTML):
|
|
6
|
+
* <script src="dist/vanilla.global.js"></script>
|
|
7
|
+
* <script>
|
|
8
|
+
* const client = SalesBot.init({ embedKey: 'pk_live_...' })
|
|
9
|
+
* // or
|
|
10
|
+
* SalesBot.widget({ embedKey: 'pk_live_...' })
|
|
11
|
+
* </script>
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { SalesBotClient } from '../core/client'
|
|
15
|
+
import { createWidget } from '../widget/widget'
|
|
16
|
+
import type { SalesBotClientOptions, WidgetOptions, WidgetInstance } from '../core/types'
|
|
17
|
+
|
|
18
|
+
export interface SalesBotGlobal {
|
|
19
|
+
/** Create a bare SalesBotClient for full programmatic control */
|
|
20
|
+
init(opts: SalesBotClientOptions): SalesBotClient
|
|
21
|
+
/** Mount the floating chat widget */
|
|
22
|
+
widget(opts: WidgetOptions): WidgetInstance
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SalesBot: SalesBotGlobal = {
|
|
26
|
+
init(opts: SalesBotClientOptions): SalesBotClient {
|
|
27
|
+
return new SalesBotClient(opts)
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
widget(opts: WidgetOptions): WidgetInstance {
|
|
31
|
+
return createWidget(opts)
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default SalesBot
|
|
36
|
+
|
|
37
|
+
// For IIFE global assignment — tsup globalName handles the window.SalesBot assignment
|
|
38
|
+
export { SalesBot }
|
package/src/vue/index.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { onUnmounted, ref } from 'vue'
|
|
2
|
+
import type { Ref } from 'vue'
|
|
3
|
+
import { SalesBotClient } from '../core/client'
|
|
4
|
+
import { SalesBotError, isSalesWorkflowTool } from '../core/types'
|
|
5
|
+
import type {
|
|
6
|
+
SalesBotClientOptions,
|
|
7
|
+
AskOptions,
|
|
8
|
+
Message,
|
|
9
|
+
DeltaEvent,
|
|
10
|
+
MessageCompleteEvent,
|
|
11
|
+
TurnStartedEvent,
|
|
12
|
+
ToolCallStartedEvent,
|
|
13
|
+
Unsubscribe,
|
|
14
|
+
} from '../core/types'
|
|
15
|
+
|
|
16
|
+
export interface UseSalesBotOptions extends SalesBotClientOptions {
|
|
17
|
+
conversationId?: string
|
|
18
|
+
/**
|
|
19
|
+
* Fires when a sales-workflow tool call starts (e.g. quotes__send_as_pdf).
|
|
20
|
+
* Convenience over manually filtering tool_call_started events via
|
|
21
|
+
* isSalesWorkflowTool. Pass-through; receives the same payload.
|
|
22
|
+
*/
|
|
23
|
+
onSalesToolCall?: (event: ToolCallStartedEvent) => void
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface UseSalesBotResult {
|
|
27
|
+
ask: (message: string, opts?: AskOptions) => Promise<void>
|
|
28
|
+
messages: Ref<Message[]>
|
|
29
|
+
isStreaming: Ref<boolean>
|
|
30
|
+
error: Ref<SalesBotError | null>
|
|
31
|
+
conversationId: Ref<string | null>
|
|
32
|
+
visitorToken: string
|
|
33
|
+
reset: () => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let msgCounter = 0
|
|
37
|
+
function nextId(): string {
|
|
38
|
+
return `vmsg-${++msgCounter}`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function useSalesBot(opts: UseSalesBotOptions): UseSalesBotResult {
|
|
42
|
+
const client = new SalesBotClient(opts)
|
|
43
|
+
if (opts.conversationId) {
|
|
44
|
+
client.setConversationId(opts.conversationId)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const messages = ref<Message[]>([])
|
|
48
|
+
const isStreaming = ref(false)
|
|
49
|
+
const error = ref<SalesBotError | null>(null)
|
|
50
|
+
const conversationId = ref<string | null>(opts.conversationId ?? null)
|
|
51
|
+
|
|
52
|
+
// Track all unsubscribe functions for cleanup
|
|
53
|
+
const unsubs: Unsubscribe[] = []
|
|
54
|
+
|
|
55
|
+
// Optional sales-workflow tool callback. Subscribe once for the client's
|
|
56
|
+
// lifetime; we read opts.onSalesToolCall at call time so callers don't have
|
|
57
|
+
// to memoize the reference.
|
|
58
|
+
unsubs.push(
|
|
59
|
+
client.on('tool_call_started', (e: ToolCallStartedEvent) => {
|
|
60
|
+
const cb = opts.onSalesToolCall
|
|
61
|
+
if (!cb) return
|
|
62
|
+
if (isSalesWorkflowTool(e.name)) {
|
|
63
|
+
cb(e)
|
|
64
|
+
}
|
|
65
|
+
}),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
async function ask(message: string, askOpts?: AskOptions): Promise<void> {
|
|
69
|
+
error.value = null
|
|
70
|
+
isStreaming.value = true
|
|
71
|
+
|
|
72
|
+
// Append user message immediately
|
|
73
|
+
const userMsgId = nextId()
|
|
74
|
+
messages.value = [...messages.value, { id: userMsgId, role: 'user', content: message }]
|
|
75
|
+
|
|
76
|
+
const assistantMsgId = nextId()
|
|
77
|
+
let assistantContent = ''
|
|
78
|
+
let assistantAdded = false
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
for await (const event of client.ask(message, askOpts)) {
|
|
82
|
+
switch (event.event) {
|
|
83
|
+
case 'turn_started': {
|
|
84
|
+
const data = event.data as TurnStartedEvent
|
|
85
|
+
conversationId.value = data.conversationId
|
|
86
|
+
break
|
|
87
|
+
}
|
|
88
|
+
case 'delta': {
|
|
89
|
+
const data = event.data as DeltaEvent
|
|
90
|
+
assistantContent += data.content
|
|
91
|
+
if (!assistantAdded) {
|
|
92
|
+
assistantAdded = true
|
|
93
|
+
messages.value = [
|
|
94
|
+
...messages.value,
|
|
95
|
+
{ id: assistantMsgId, role: 'assistant', content: assistantContent, streaming: true },
|
|
96
|
+
]
|
|
97
|
+
} else {
|
|
98
|
+
messages.value = messages.value.map((m) =>
|
|
99
|
+
m.id === assistantMsgId ? { ...m, content: assistantContent } : m,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
break
|
|
103
|
+
}
|
|
104
|
+
case 'message_complete': {
|
|
105
|
+
const data = event.data as MessageCompleteEvent
|
|
106
|
+
messages.value = messages.value.map((m) =>
|
|
107
|
+
m.id === assistantMsgId ? { ...m, content: data.content, streaming: false } : m,
|
|
108
|
+
)
|
|
109
|
+
break
|
|
110
|
+
}
|
|
111
|
+
case 'done':
|
|
112
|
+
isStreaming.value = false
|
|
113
|
+
break
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
} catch (err) {
|
|
117
|
+
const sbErr =
|
|
118
|
+
err instanceof SalesBotError
|
|
119
|
+
? err
|
|
120
|
+
: new SalesBotError({ code: 'internal', message: String(err), retryable: false })
|
|
121
|
+
error.value = sbErr
|
|
122
|
+
if (!assistantAdded) {
|
|
123
|
+
messages.value = messages.value.filter((m) => m.id !== assistantMsgId)
|
|
124
|
+
}
|
|
125
|
+
} finally {
|
|
126
|
+
isStreaming.value = false
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function reset(): void {
|
|
131
|
+
messages.value = []
|
|
132
|
+
error.value = null
|
|
133
|
+
conversationId.value = null
|
|
134
|
+
client.setConversationId(null)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Clean up event bus subscriptions on unmount
|
|
138
|
+
onUnmounted(() => {
|
|
139
|
+
for (const unsub of unsubs) unsub()
|
|
140
|
+
unsubs.length = 0
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
ask,
|
|
145
|
+
messages,
|
|
146
|
+
isStreaming,
|
|
147
|
+
error,
|
|
148
|
+
conversationId,
|
|
149
|
+
visitorToken: client.getVisitorToken(),
|
|
150
|
+
reset,
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight markdown renderer for assistant messages.
|
|
3
|
+
*
|
|
4
|
+
* Block-level: paragraphs (blank line separators), unordered lists (- or *),
|
|
5
|
+
* ordered lists (1. 2. …), line breaks inside paragraphs.
|
|
6
|
+
* Inline: **bold**, *italic*, `code`, [text](https://…).
|
|
7
|
+
*
|
|
8
|
+
* Streaming-safe: tolerates partial input (incomplete blocks at end). All
|
|
9
|
+
* source text is HTML-escaped first, so no XSS even when assistant content
|
|
10
|
+
* contains `<script>` etc.
|
|
11
|
+
*
|
|
12
|
+
* Intentionally small (≈120 lines, no external deps). For richer markdown
|
|
13
|
+
* (tables, code fences, headings) reach for marked / markdown-it.
|
|
14
|
+
*/
|
|
15
|
+
export function renderMarkdown(text: string): string {
|
|
16
|
+
// 1. Escape HTML first — every subsequent transform emits trusted tags only.
|
|
17
|
+
const escaped = text
|
|
18
|
+
.replace(/&/g, '&')
|
|
19
|
+
.replace(/</g, '<')
|
|
20
|
+
.replace(/>/g, '>')
|
|
21
|
+
|
|
22
|
+
// 2. Split into blocks on blank lines (one or more \n\s*\n).
|
|
23
|
+
const blocks = escaped.split(/\n\s*\n+/)
|
|
24
|
+
return blocks.map(renderBlock).filter(Boolean).join('')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function renderBlock(raw: string): string {
|
|
28
|
+
const trimmed = raw.replace(/^\s+|\s+$/g, '')
|
|
29
|
+
if (!trimmed) return ''
|
|
30
|
+
|
|
31
|
+
const lines = trimmed.split('\n')
|
|
32
|
+
|
|
33
|
+
// Unordered list: every non-empty line starts with - or * followed by space.
|
|
34
|
+
if (lines.every((l) => l.trim() === '' || /^[\-*]\s+/.test(l.trim()))) {
|
|
35
|
+
const items = lines
|
|
36
|
+
.filter((l) => l.trim())
|
|
37
|
+
.map((l) => `<li>${inline(l.trim().replace(/^[\-*]\s+/, ''))}</li>`)
|
|
38
|
+
return `<ul>${items.join('')}</ul>`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Ordered list: every non-empty line starts with N. followed by space.
|
|
42
|
+
if (lines.every((l) => l.trim() === '' || /^\d+\.\s+/.test(l.trim()))) {
|
|
43
|
+
const items = lines
|
|
44
|
+
.filter((l) => l.trim())
|
|
45
|
+
.map((l) => `<li>${inline(l.trim().replace(/^\d+\.\s+/, ''))}</li>`)
|
|
46
|
+
return `<ol>${items.join('')}</ol>`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Paragraph: inline-format each line, join with <br> for soft breaks.
|
|
50
|
+
const html = lines.map(inline).join('<br>')
|
|
51
|
+
return `<p>${html}</p>`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function inline(s: string): string {
|
|
55
|
+
return (
|
|
56
|
+
s
|
|
57
|
+
// **bold**
|
|
58
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
59
|
+
// *italic* (not inside bold)
|
|
60
|
+
.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>')
|
|
61
|
+
// `code`
|
|
62
|
+
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
63
|
+
// [text](https://...) — only https links
|
|
64
|
+
.replace(
|
|
65
|
+
/\[([^\]]+)\]\((https:\/\/[^)]+)\)/g,
|
|
66
|
+
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
}
|