shuvmaki 0.4.26
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/bin.js +70 -0
- package/dist/ai-tool-to-genai.js +210 -0
- package/dist/ai-tool-to-genai.test.js +267 -0
- package/dist/channel-management.js +97 -0
- package/dist/cli.js +709 -0
- package/dist/commands/abort.js +78 -0
- package/dist/commands/add-project.js +98 -0
- package/dist/commands/agent.js +152 -0
- package/dist/commands/ask-question.js +183 -0
- package/dist/commands/create-new-project.js +78 -0
- package/dist/commands/fork.js +186 -0
- package/dist/commands/model.js +313 -0
- package/dist/commands/permissions.js +126 -0
- package/dist/commands/queue.js +129 -0
- package/dist/commands/resume.js +145 -0
- package/dist/commands/session.js +142 -0
- package/dist/commands/share.js +80 -0
- package/dist/commands/types.js +2 -0
- package/dist/commands/undo-redo.js +161 -0
- package/dist/commands/user-command.js +145 -0
- package/dist/database.js +184 -0
- package/dist/discord-bot.js +384 -0
- package/dist/discord-utils.js +217 -0
- package/dist/escape-backticks.test.js +410 -0
- package/dist/format-tables.js +96 -0
- package/dist/format-tables.test.js +418 -0
- package/dist/genai-worker-wrapper.js +109 -0
- package/dist/genai-worker.js +297 -0
- package/dist/genai.js +232 -0
- package/dist/interaction-handler.js +144 -0
- package/dist/logger.js +51 -0
- package/dist/markdown.js +310 -0
- package/dist/markdown.test.js +262 -0
- package/dist/message-formatting.js +273 -0
- package/dist/message-formatting.test.js +73 -0
- package/dist/openai-realtime.js +228 -0
- package/dist/opencode.js +216 -0
- package/dist/session-handler.js +580 -0
- package/dist/system-message.js +61 -0
- package/dist/tools.js +356 -0
- package/dist/utils.js +85 -0
- package/dist/voice-handler.js +541 -0
- package/dist/voice.js +314 -0
- package/dist/worker-types.js +4 -0
- package/dist/xml.js +92 -0
- package/dist/xml.test.js +32 -0
- package/package.json +60 -0
- package/src/__snapshots__/compact-session-context-no-system.md +35 -0
- package/src/__snapshots__/compact-session-context.md +47 -0
- package/src/ai-tool-to-genai.test.ts +296 -0
- package/src/ai-tool-to-genai.ts +255 -0
- package/src/channel-management.ts +161 -0
- package/src/cli.ts +1010 -0
- package/src/commands/abort.ts +94 -0
- package/src/commands/add-project.ts +139 -0
- package/src/commands/agent.ts +201 -0
- package/src/commands/ask-question.ts +276 -0
- package/src/commands/create-new-project.ts +111 -0
- package/src/commands/fork.ts +257 -0
- package/src/commands/model.ts +402 -0
- package/src/commands/permissions.ts +146 -0
- package/src/commands/queue.ts +181 -0
- package/src/commands/resume.ts +230 -0
- package/src/commands/session.ts +184 -0
- package/src/commands/share.ts +96 -0
- package/src/commands/types.ts +25 -0
- package/src/commands/undo-redo.ts +213 -0
- package/src/commands/user-command.ts +178 -0
- package/src/database.ts +220 -0
- package/src/discord-bot.ts +513 -0
- package/src/discord-utils.ts +282 -0
- package/src/escape-backticks.test.ts +447 -0
- package/src/format-tables.test.ts +440 -0
- package/src/format-tables.ts +110 -0
- package/src/genai-worker-wrapper.ts +160 -0
- package/src/genai-worker.ts +366 -0
- package/src/genai.ts +321 -0
- package/src/interaction-handler.ts +187 -0
- package/src/logger.ts +57 -0
- package/src/markdown.test.ts +358 -0
- package/src/markdown.ts +365 -0
- package/src/message-formatting.test.ts +81 -0
- package/src/message-formatting.ts +340 -0
- package/src/openai-realtime.ts +363 -0
- package/src/opencode.ts +277 -0
- package/src/session-handler.ts +758 -0
- package/src/system-message.ts +62 -0
- package/src/tools.ts +428 -0
- package/src/utils.ts +118 -0
- package/src/voice-handler.ts +760 -0
- package/src/voice.ts +432 -0
- package/src/worker-types.ts +66 -0
- package/src/xml.test.ts +37 -0
- package/src/xml.ts +121 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
// Discord-specific utility functions.
|
|
2
|
+
// Handles markdown splitting for Discord's 2000-char limit, code block escaping,
|
|
3
|
+
// thread message sending, and channel metadata extraction from topic tags.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
ChannelType,
|
|
7
|
+
type Message,
|
|
8
|
+
type TextChannel,
|
|
9
|
+
type ThreadChannel,
|
|
10
|
+
} from 'discord.js'
|
|
11
|
+
import { Lexer } from 'marked'
|
|
12
|
+
import { extractTagsArrays } from './xml.js'
|
|
13
|
+
import { formatMarkdownTables } from './format-tables.js'
|
|
14
|
+
import { createLogger } from './logger.js'
|
|
15
|
+
|
|
16
|
+
const discordLogger = createLogger('DISCORD')
|
|
17
|
+
|
|
18
|
+
export const SILENT_MESSAGE_FLAGS = 4 | 4096
|
|
19
|
+
// Same as SILENT but without SuppressNotifications - triggers badge/notification
|
|
20
|
+
export const NOTIFY_MESSAGE_FLAGS = 4
|
|
21
|
+
|
|
22
|
+
export function escapeBackticksInCodeBlocks(markdown: string): string {
|
|
23
|
+
const lexer = new Lexer()
|
|
24
|
+
const tokens = lexer.lex(markdown)
|
|
25
|
+
|
|
26
|
+
let result = ''
|
|
27
|
+
|
|
28
|
+
for (const token of tokens) {
|
|
29
|
+
if (token.type === 'code') {
|
|
30
|
+
const escapedCode = token.text.replace(/`/g, '\\`')
|
|
31
|
+
result += '```' + (token.lang || '') + '\n' + escapedCode + '\n```\n'
|
|
32
|
+
} else {
|
|
33
|
+
result += token.raw
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return result
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type LineInfo = {
|
|
41
|
+
text: string
|
|
42
|
+
inCodeBlock: boolean
|
|
43
|
+
lang: string
|
|
44
|
+
isOpeningFence: boolean
|
|
45
|
+
isClosingFence: boolean
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function splitMarkdownForDiscord({
|
|
49
|
+
content,
|
|
50
|
+
maxLength,
|
|
51
|
+
}: {
|
|
52
|
+
content: string
|
|
53
|
+
maxLength: number
|
|
54
|
+
}): string[] {
|
|
55
|
+
if (content.length <= maxLength) {
|
|
56
|
+
return [content]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const lexer = new Lexer()
|
|
60
|
+
const tokens = lexer.lex(content)
|
|
61
|
+
|
|
62
|
+
const lines: LineInfo[] = []
|
|
63
|
+
for (const token of tokens) {
|
|
64
|
+
if (token.type === 'code') {
|
|
65
|
+
const lang = token.lang || ''
|
|
66
|
+
lines.push({ text: '```' + lang + '\n', inCodeBlock: false, lang, isOpeningFence: true, isClosingFence: false })
|
|
67
|
+
const codeLines = token.text.split('\n')
|
|
68
|
+
for (const codeLine of codeLines) {
|
|
69
|
+
lines.push({ text: codeLine + '\n', inCodeBlock: true, lang, isOpeningFence: false, isClosingFence: false })
|
|
70
|
+
}
|
|
71
|
+
lines.push({ text: '```\n', inCodeBlock: false, lang: '', isOpeningFence: false, isClosingFence: true })
|
|
72
|
+
} else {
|
|
73
|
+
const rawLines = token.raw.split('\n')
|
|
74
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
75
|
+
const isLast = i === rawLines.length - 1
|
|
76
|
+
const text = isLast ? rawLines[i]! : rawLines[i]! + '\n'
|
|
77
|
+
if (text) {
|
|
78
|
+
lines.push({ text, inCodeBlock: false, lang: '', isOpeningFence: false, isClosingFence: false })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const chunks: string[] = []
|
|
85
|
+
let currentChunk = ''
|
|
86
|
+
let currentLang: string | null = null
|
|
87
|
+
|
|
88
|
+
// helper to split a long line into smaller pieces at word boundaries or hard breaks
|
|
89
|
+
const splitLongLine = (text: string, available: number, inCode: boolean): string[] => {
|
|
90
|
+
const pieces: string[] = []
|
|
91
|
+
let remaining = text
|
|
92
|
+
|
|
93
|
+
while (remaining.length > available) {
|
|
94
|
+
let splitAt = available
|
|
95
|
+
// for non-code, try to split at word boundary
|
|
96
|
+
if (!inCode) {
|
|
97
|
+
const lastSpace = remaining.lastIndexOf(' ', available)
|
|
98
|
+
if (lastSpace > available * 0.5) {
|
|
99
|
+
splitAt = lastSpace + 1
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
pieces.push(remaining.slice(0, splitAt))
|
|
103
|
+
remaining = remaining.slice(splitAt)
|
|
104
|
+
}
|
|
105
|
+
if (remaining) {
|
|
106
|
+
pieces.push(remaining)
|
|
107
|
+
}
|
|
108
|
+
return pieces
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (const line of lines) {
|
|
112
|
+
const wouldExceed = currentChunk.length + line.text.length > maxLength
|
|
113
|
+
|
|
114
|
+
if (wouldExceed) {
|
|
115
|
+
// handle case where single line is longer than maxLength
|
|
116
|
+
if (line.text.length > maxLength) {
|
|
117
|
+
// first, flush current chunk if any
|
|
118
|
+
if (currentChunk) {
|
|
119
|
+
if (currentLang !== null) {
|
|
120
|
+
currentChunk += '```\n'
|
|
121
|
+
}
|
|
122
|
+
chunks.push(currentChunk)
|
|
123
|
+
currentChunk = ''
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// calculate overhead for code block markers
|
|
127
|
+
const codeBlockOverhead = line.inCodeBlock ? ('```' + line.lang + '\n').length + '```\n'.length : 0
|
|
128
|
+
const availablePerChunk = maxLength - codeBlockOverhead - 50 // safety margin
|
|
129
|
+
|
|
130
|
+
const pieces = splitLongLine(line.text, availablePerChunk, line.inCodeBlock)
|
|
131
|
+
|
|
132
|
+
for (let i = 0; i < pieces.length; i++) {
|
|
133
|
+
const piece = pieces[i]!
|
|
134
|
+
if (line.inCodeBlock) {
|
|
135
|
+
chunks.push('```' + line.lang + '\n' + piece + '```\n')
|
|
136
|
+
} else {
|
|
137
|
+
chunks.push(piece)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
currentLang = null
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// normal case: line fits in a chunk but current chunk would overflow
|
|
146
|
+
if (currentChunk) {
|
|
147
|
+
if (currentLang !== null) {
|
|
148
|
+
currentChunk += '```\n'
|
|
149
|
+
}
|
|
150
|
+
chunks.push(currentChunk)
|
|
151
|
+
|
|
152
|
+
if (line.isClosingFence && currentLang !== null) {
|
|
153
|
+
currentChunk = ''
|
|
154
|
+
currentLang = null
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (line.inCodeBlock || line.isOpeningFence) {
|
|
159
|
+
const lang = line.lang
|
|
160
|
+
currentChunk = '```' + lang + '\n'
|
|
161
|
+
if (!line.isOpeningFence) {
|
|
162
|
+
currentChunk += line.text
|
|
163
|
+
}
|
|
164
|
+
currentLang = lang
|
|
165
|
+
} else {
|
|
166
|
+
currentChunk = line.text
|
|
167
|
+
currentLang = null
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
// currentChunk is empty but line still exceeds - shouldn't happen after above check
|
|
171
|
+
currentChunk = line.text
|
|
172
|
+
if (line.inCodeBlock || line.isOpeningFence) {
|
|
173
|
+
currentLang = line.lang
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} else {
|
|
177
|
+
currentChunk += line.text
|
|
178
|
+
if (line.inCodeBlock || line.isOpeningFence) {
|
|
179
|
+
currentLang = line.lang
|
|
180
|
+
} else if (line.isClosingFence) {
|
|
181
|
+
currentLang = null
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (currentChunk) {
|
|
187
|
+
chunks.push(currentChunk)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return chunks
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function sendThreadMessage(
|
|
194
|
+
thread: ThreadChannel,
|
|
195
|
+
content: string,
|
|
196
|
+
options?: { flags?: number }
|
|
197
|
+
): Promise<Message> {
|
|
198
|
+
const MAX_LENGTH = 2000
|
|
199
|
+
|
|
200
|
+
content = formatMarkdownTables(content)
|
|
201
|
+
content = escapeBackticksInCodeBlocks(content)
|
|
202
|
+
|
|
203
|
+
// If custom flags provided, send as single message (no chunking)
|
|
204
|
+
if (options?.flags !== undefined) {
|
|
205
|
+
return thread.send({ content, flags: options.flags })
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const chunks = splitMarkdownForDiscord({ content, maxLength: MAX_LENGTH })
|
|
209
|
+
|
|
210
|
+
if (chunks.length > 1) {
|
|
211
|
+
discordLogger.log(
|
|
212
|
+
`MESSAGE: Splitting ${content.length} chars into ${chunks.length} messages`,
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let firstMessage: Message | undefined
|
|
217
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
218
|
+
const chunk = chunks[i]
|
|
219
|
+
if (!chunk) {
|
|
220
|
+
continue
|
|
221
|
+
}
|
|
222
|
+
const message = await thread.send({ content: chunk, flags: SILENT_MESSAGE_FLAGS })
|
|
223
|
+
if (i === 0) {
|
|
224
|
+
firstMessage = message
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return firstMessage!
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function resolveTextChannel(
|
|
232
|
+
channel: TextChannel | ThreadChannel | null | undefined,
|
|
233
|
+
): Promise<TextChannel | null> {
|
|
234
|
+
if (!channel) {
|
|
235
|
+
return null
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (channel.type === ChannelType.GuildText) {
|
|
239
|
+
return channel as TextChannel
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (
|
|
243
|
+
channel.type === ChannelType.PublicThread ||
|
|
244
|
+
channel.type === ChannelType.PrivateThread ||
|
|
245
|
+
channel.type === ChannelType.AnnouncementThread
|
|
246
|
+
) {
|
|
247
|
+
const parentId = channel.parentId
|
|
248
|
+
if (parentId) {
|
|
249
|
+
const parent = await channel.guild.channels.fetch(parentId)
|
|
250
|
+
if (parent?.type === ChannelType.GuildText) {
|
|
251
|
+
return parent as TextChannel
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return null
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function escapeDiscordFormatting(text: string): string {
|
|
260
|
+
return text
|
|
261
|
+
.replace(/```/g, '\\`\\`\\`')
|
|
262
|
+
.replace(/````/g, '\\`\\`\\`\\`')
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function getKimakiMetadata(textChannel: TextChannel | null): {
|
|
266
|
+
projectDirectory?: string
|
|
267
|
+
channelAppId?: string
|
|
268
|
+
} {
|
|
269
|
+
if (!textChannel?.topic) {
|
|
270
|
+
return {}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const extracted = extractTagsArrays({
|
|
274
|
+
xml: textChannel.topic,
|
|
275
|
+
tags: ['kimaki.directory', 'kimaki.app'],
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
const projectDirectory = extracted['kimaki.directory']?.[0]?.trim()
|
|
279
|
+
const channelAppId = extracted['kimaki.app']?.[0]?.trim()
|
|
280
|
+
|
|
281
|
+
return { projectDirectory, channelAppId }
|
|
282
|
+
}
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import { test, expect } from 'vitest'
|
|
2
|
+
import { Lexer } from 'marked'
|
|
3
|
+
import { escapeBackticksInCodeBlocks, splitMarkdownForDiscord } from './discord-utils.js'
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
test('escapes single backticks in code blocks', () => {
|
|
8
|
+
const input = '```js\nconst x = `hello`\n```'
|
|
9
|
+
const result = escapeBackticksInCodeBlocks(input)
|
|
10
|
+
|
|
11
|
+
expect(result).toMatchInlineSnapshot(`
|
|
12
|
+
"\`\`\`js
|
|
13
|
+
const x = \\\`hello\\\`
|
|
14
|
+
\`\`\`
|
|
15
|
+
"
|
|
16
|
+
`)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('escapes backticks in code blocks with language', () => {
|
|
20
|
+
const input = '```typescript\nconst greeting = `Hello, ${name}!`\nconst inline = `test`\n```'
|
|
21
|
+
const result = escapeBackticksInCodeBlocks(input)
|
|
22
|
+
|
|
23
|
+
expect(result).toMatchInlineSnapshot(`
|
|
24
|
+
"\`\`\`typescript
|
|
25
|
+
const greeting = \\\`Hello, \${name}!\\\`
|
|
26
|
+
const inline = \\\`test\\\`
|
|
27
|
+
\`\`\`
|
|
28
|
+
"
|
|
29
|
+
`)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('does not escape backticks outside code blocks', () => {
|
|
33
|
+
const input = 'This is `inline code` and this is a code block:\n```\nconst x = `template`\n```'
|
|
34
|
+
const result = escapeBackticksInCodeBlocks(input)
|
|
35
|
+
|
|
36
|
+
expect(result).toMatchInlineSnapshot(`
|
|
37
|
+
"This is \`inline code\` and this is a code block:
|
|
38
|
+
\`\`\`
|
|
39
|
+
const x = \\\`template\\\`
|
|
40
|
+
\`\`\`
|
|
41
|
+
"
|
|
42
|
+
`)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('handles multiple code blocks', () => {
|
|
46
|
+
const input = `First block:
|
|
47
|
+
\`\`\`js
|
|
48
|
+
const a = \`test\`
|
|
49
|
+
\`\`\`
|
|
50
|
+
|
|
51
|
+
Some text with \`inline\` code
|
|
52
|
+
|
|
53
|
+
Second block:
|
|
54
|
+
\`\`\`python
|
|
55
|
+
name = f\`hello {world}\`
|
|
56
|
+
\`\`\``
|
|
57
|
+
|
|
58
|
+
const result = escapeBackticksInCodeBlocks(input)
|
|
59
|
+
|
|
60
|
+
expect(result).toMatchInlineSnapshot(`
|
|
61
|
+
"First block:
|
|
62
|
+
\`\`\`js
|
|
63
|
+
const a = \\\`test\\\`
|
|
64
|
+
\`\`\`
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
Some text with \`inline\` code
|
|
68
|
+
|
|
69
|
+
Second block:
|
|
70
|
+
\`\`\`python
|
|
71
|
+
name = f\\\`hello {world}\\\`
|
|
72
|
+
\`\`\`
|
|
73
|
+
"
|
|
74
|
+
`)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('handles code blocks without language', () => {
|
|
78
|
+
const input = '```\nconst x = `value`\n```'
|
|
79
|
+
const result = escapeBackticksInCodeBlocks(input)
|
|
80
|
+
|
|
81
|
+
expect(result).toMatchInlineSnapshot(`
|
|
82
|
+
"\`\`\`
|
|
83
|
+
const x = \\\`value\\\`
|
|
84
|
+
\`\`\`
|
|
85
|
+
"
|
|
86
|
+
`)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('handles nested backticks in code blocks', () => {
|
|
90
|
+
const input = '```js\nconst nested = `outer ${`inner`} text`\n```'
|
|
91
|
+
const result = escapeBackticksInCodeBlocks(input)
|
|
92
|
+
|
|
93
|
+
expect(result).toMatchInlineSnapshot(`
|
|
94
|
+
"\`\`\`js
|
|
95
|
+
const nested = \\\`outer \${\\\`inner\\\`} text\\\`
|
|
96
|
+
\`\`\`
|
|
97
|
+
"
|
|
98
|
+
`)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test('preserves markdown outside code blocks', () => {
|
|
102
|
+
const input = `# Heading
|
|
103
|
+
|
|
104
|
+
This is **bold** and *italic* text
|
|
105
|
+
|
|
106
|
+
\`\`\`js
|
|
107
|
+
const code = \`with template\`
|
|
108
|
+
\`\`\`
|
|
109
|
+
|
|
110
|
+
- List item 1
|
|
111
|
+
- List item 2`
|
|
112
|
+
|
|
113
|
+
const result = escapeBackticksInCodeBlocks(input)
|
|
114
|
+
|
|
115
|
+
expect(result).toMatchInlineSnapshot(`
|
|
116
|
+
"# Heading
|
|
117
|
+
|
|
118
|
+
This is **bold** and *italic* text
|
|
119
|
+
|
|
120
|
+
\`\`\`js
|
|
121
|
+
const code = \\\`with template\\\`
|
|
122
|
+
\`\`\`
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
- List item 1
|
|
126
|
+
- List item 2"
|
|
127
|
+
`)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('does not escape code block delimiter backticks', () => {
|
|
131
|
+
const input = '```js\nconst x = `hello`\n```'
|
|
132
|
+
const result = escapeBackticksInCodeBlocks(input)
|
|
133
|
+
|
|
134
|
+
expect(result.startsWith('```')).toBe(true)
|
|
135
|
+
expect(result.endsWith('```\n')).toBe(true)
|
|
136
|
+
expect(result).toContain('\\`hello\\`')
|
|
137
|
+
expect(result).not.toContain('\\`\\`\\`js')
|
|
138
|
+
expect(result).not.toContain('\\`\\`\\`\n')
|
|
139
|
+
|
|
140
|
+
expect(result).toMatchInlineSnapshot(`
|
|
141
|
+
"\`\`\`js
|
|
142
|
+
const x = \\\`hello\\\`
|
|
143
|
+
\`\`\`
|
|
144
|
+
"
|
|
145
|
+
`)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('splitMarkdownForDiscord returns single chunk for short content', () => {
|
|
149
|
+
const result = splitMarkdownForDiscord({
|
|
150
|
+
content: 'Hello world',
|
|
151
|
+
maxLength: 100,
|
|
152
|
+
})
|
|
153
|
+
expect(result).toMatchInlineSnapshot(`
|
|
154
|
+
[
|
|
155
|
+
"Hello world",
|
|
156
|
+
]
|
|
157
|
+
`)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('splitMarkdownForDiscord splits at line boundaries', () => {
|
|
161
|
+
const result = splitMarkdownForDiscord({
|
|
162
|
+
content: 'Line 1\nLine 2\nLine 3\nLine 4',
|
|
163
|
+
maxLength: 15,
|
|
164
|
+
})
|
|
165
|
+
expect(result).toMatchInlineSnapshot(`
|
|
166
|
+
[
|
|
167
|
+
"Line 1
|
|
168
|
+
Line 2
|
|
169
|
+
",
|
|
170
|
+
"Line 3
|
|
171
|
+
Line 4",
|
|
172
|
+
]
|
|
173
|
+
`)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('splitMarkdownForDiscord preserves code blocks when not split', () => {
|
|
177
|
+
const result = splitMarkdownForDiscord({
|
|
178
|
+
content: '```js\nconst x = 1\n```',
|
|
179
|
+
maxLength: 100,
|
|
180
|
+
})
|
|
181
|
+
expect(result).toMatchInlineSnapshot(`
|
|
182
|
+
[
|
|
183
|
+
"\`\`\`js
|
|
184
|
+
const x = 1
|
|
185
|
+
\`\`\`",
|
|
186
|
+
]
|
|
187
|
+
`)
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('splitMarkdownForDiscord adds closing and opening fences when splitting code block', () => {
|
|
191
|
+
const result = splitMarkdownForDiscord({
|
|
192
|
+
content: '```js\nline1\nline2\nline3\nline4\n```',
|
|
193
|
+
maxLength: 20,
|
|
194
|
+
})
|
|
195
|
+
expect(result).toMatchInlineSnapshot(`
|
|
196
|
+
[
|
|
197
|
+
"\`\`\`js
|
|
198
|
+
line1
|
|
199
|
+
line2
|
|
200
|
+
\`\`\`
|
|
201
|
+
",
|
|
202
|
+
"\`\`\`js
|
|
203
|
+
line3
|
|
204
|
+
line4
|
|
205
|
+
\`\`\`
|
|
206
|
+
",
|
|
207
|
+
]
|
|
208
|
+
`)
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
test('splitMarkdownForDiscord handles code block with language', () => {
|
|
212
|
+
const result = splitMarkdownForDiscord({
|
|
213
|
+
content: '```typescript\nconst a = 1\nconst b = 2\n```',
|
|
214
|
+
maxLength: 30,
|
|
215
|
+
})
|
|
216
|
+
expect(result).toMatchInlineSnapshot(`
|
|
217
|
+
[
|
|
218
|
+
"\`\`\`typescript
|
|
219
|
+
const a = 1
|
|
220
|
+
\`\`\`
|
|
221
|
+
",
|
|
222
|
+
"\`\`\`typescript
|
|
223
|
+
const b = 2
|
|
224
|
+
\`\`\`
|
|
225
|
+
",
|
|
226
|
+
]
|
|
227
|
+
`)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
test('splitMarkdownForDiscord handles mixed content with code blocks', () => {
|
|
231
|
+
const result = splitMarkdownForDiscord({
|
|
232
|
+
content: 'Text before\n```js\ncode\n```\nText after',
|
|
233
|
+
maxLength: 25,
|
|
234
|
+
})
|
|
235
|
+
expect(result).toMatchInlineSnapshot(`
|
|
236
|
+
[
|
|
237
|
+
"Text before
|
|
238
|
+
\`\`\`js
|
|
239
|
+
code
|
|
240
|
+
\`\`\`
|
|
241
|
+
",
|
|
242
|
+
"Text after",
|
|
243
|
+
]
|
|
244
|
+
`)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test('splitMarkdownForDiscord handles code block without language', () => {
|
|
248
|
+
const result = splitMarkdownForDiscord({
|
|
249
|
+
content: '```\nline1\nline2\n```',
|
|
250
|
+
maxLength: 12,
|
|
251
|
+
})
|
|
252
|
+
expect(result).toMatchInlineSnapshot(`
|
|
253
|
+
[
|
|
254
|
+
"\`\`\`
|
|
255
|
+
line1
|
|
256
|
+
\`\`\`
|
|
257
|
+
",
|
|
258
|
+
"\`\`\`
|
|
259
|
+
line2
|
|
260
|
+
\`\`\`
|
|
261
|
+
",
|
|
262
|
+
]
|
|
263
|
+
`)
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
test('splitMarkdownForDiscord handles multiple consecutive code blocks', () => {
|
|
267
|
+
const result = splitMarkdownForDiscord({
|
|
268
|
+
content: '```js\nfoo\n```\n```py\nbar\n```',
|
|
269
|
+
maxLength: 20,
|
|
270
|
+
})
|
|
271
|
+
expect(result).toMatchInlineSnapshot(`
|
|
272
|
+
[
|
|
273
|
+
"\`\`\`js
|
|
274
|
+
foo
|
|
275
|
+
\`\`\`
|
|
276
|
+
\`\`\`py
|
|
277
|
+
\`\`\`
|
|
278
|
+
",
|
|
279
|
+
"\`\`\`py
|
|
280
|
+
bar
|
|
281
|
+
\`\`\`
|
|
282
|
+
",
|
|
283
|
+
]
|
|
284
|
+
`)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
test('splitMarkdownForDiscord handles empty code block', () => {
|
|
288
|
+
const result = splitMarkdownForDiscord({
|
|
289
|
+
content: 'before\n```\n```\nafter',
|
|
290
|
+
maxLength: 50,
|
|
291
|
+
})
|
|
292
|
+
expect(result).toMatchInlineSnapshot(`
|
|
293
|
+
[
|
|
294
|
+
"before
|
|
295
|
+
\`\`\`
|
|
296
|
+
\`\`\`
|
|
297
|
+
after",
|
|
298
|
+
]
|
|
299
|
+
`)
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
test('splitMarkdownForDiscord handles content exactly at maxLength', () => {
|
|
303
|
+
const result = splitMarkdownForDiscord({
|
|
304
|
+
content: '12345678901234567890',
|
|
305
|
+
maxLength: 20,
|
|
306
|
+
})
|
|
307
|
+
expect(result).toMatchInlineSnapshot(`
|
|
308
|
+
[
|
|
309
|
+
"12345678901234567890",
|
|
310
|
+
]
|
|
311
|
+
`)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
test('splitMarkdownForDiscord handles code block only', () => {
|
|
315
|
+
const result = splitMarkdownForDiscord({
|
|
316
|
+
content: '```ts\nconst x = 1\n```',
|
|
317
|
+
maxLength: 15,
|
|
318
|
+
})
|
|
319
|
+
expect(result).toMatchInlineSnapshot(`
|
|
320
|
+
[
|
|
321
|
+
"\`\`\`ts
|
|
322
|
+
\`\`\`
|
|
323
|
+
",
|
|
324
|
+
"\`\`\`ts
|
|
325
|
+
const x = 1
|
|
326
|
+
\`\`\`
|
|
327
|
+
",
|
|
328
|
+
]
|
|
329
|
+
`)
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
test('splitMarkdownForDiscord handles code block at start with text after', () => {
|
|
333
|
+
const result = splitMarkdownForDiscord({
|
|
334
|
+
content: '```js\ncode\n```\nSome text after',
|
|
335
|
+
maxLength: 20,
|
|
336
|
+
})
|
|
337
|
+
expect(result).toMatchInlineSnapshot(`
|
|
338
|
+
[
|
|
339
|
+
"\`\`\`js
|
|
340
|
+
code
|
|
341
|
+
\`\`\`
|
|
342
|
+
",
|
|
343
|
+
"Some text after",
|
|
344
|
+
]
|
|
345
|
+
`)
|
|
346
|
+
})
|
|
347
|
+
|
|
348
|
+
test('splitMarkdownForDiscord handles text before code block at end', () => {
|
|
349
|
+
const result = splitMarkdownForDiscord({
|
|
350
|
+
content: 'Some text before\n```js\ncode\n```',
|
|
351
|
+
maxLength: 25,
|
|
352
|
+
})
|
|
353
|
+
expect(result).toMatchInlineSnapshot(`
|
|
354
|
+
[
|
|
355
|
+
"Some text before
|
|
356
|
+
\`\`\`js
|
|
357
|
+
\`\`\`
|
|
358
|
+
",
|
|
359
|
+
"\`\`\`js
|
|
360
|
+
code
|
|
361
|
+
\`\`\`
|
|
362
|
+
",
|
|
363
|
+
]
|
|
364
|
+
`)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
test('splitMarkdownForDiscord handles very long line inside code block', () => {
|
|
368
|
+
const result = splitMarkdownForDiscord({
|
|
369
|
+
content: '```js\nshort\nveryverylonglinethatexceedsmaxlength\nshort\n```',
|
|
370
|
+
maxLength: 25,
|
|
371
|
+
})
|
|
372
|
+
expect(result).toMatchInlineSnapshot(`
|
|
373
|
+
[
|
|
374
|
+
"\`\`\`js
|
|
375
|
+
short
|
|
376
|
+
\`\`\`
|
|
377
|
+
",
|
|
378
|
+
"\`\`\`js
|
|
379
|
+
veryverylonglinethatexceedsmaxlength
|
|
380
|
+
\`\`\`
|
|
381
|
+
",
|
|
382
|
+
"\`\`\`js
|
|
383
|
+
short
|
|
384
|
+
\`\`\`
|
|
385
|
+
",
|
|
386
|
+
]
|
|
387
|
+
`)
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
test('splitMarkdownForDiscord handles realistic long markdown with code block', () => {
|
|
391
|
+
const content = `Here is some explanation text before the code.
|
|
392
|
+
|
|
393
|
+
\`\`\`typescript
|
|
394
|
+
export function calculateTotal(items: Item[]): number {
|
|
395
|
+
let total = 0
|
|
396
|
+
for (const item of items) {
|
|
397
|
+
total += item.price * item.quantity
|
|
398
|
+
}
|
|
399
|
+
return total
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function formatCurrency(amount: number): string {
|
|
403
|
+
return new Intl.NumberFormat('en-US', {
|
|
404
|
+
style: 'currency',
|
|
405
|
+
currency: 'USD',
|
|
406
|
+
}).format(amount)
|
|
407
|
+
}
|
|
408
|
+
\`\`\`
|
|
409
|
+
|
|
410
|
+
And here is some text after the code block.`
|
|
411
|
+
|
|
412
|
+
const result = splitMarkdownForDiscord({
|
|
413
|
+
content,
|
|
414
|
+
maxLength: 200,
|
|
415
|
+
})
|
|
416
|
+
expect(result).toMatchInlineSnapshot(`
|
|
417
|
+
[
|
|
418
|
+
"Here is some explanation text before the code.
|
|
419
|
+
|
|
420
|
+
\`\`\`typescript
|
|
421
|
+
export function calculateTotal(items: Item[]): number {
|
|
422
|
+
let total = 0
|
|
423
|
+
for (const item of items) {
|
|
424
|
+
\`\`\`
|
|
425
|
+
",
|
|
426
|
+
"\`\`\`typescript
|
|
427
|
+
total += item.price * item.quantity
|
|
428
|
+
}
|
|
429
|
+
return total
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function formatCurrency(amount: number): string {
|
|
433
|
+
return new Intl.NumberFormat('en-US', {
|
|
434
|
+
style: 'currency',
|
|
435
|
+
\`\`\`
|
|
436
|
+
",
|
|
437
|
+
"\`\`\`typescript
|
|
438
|
+
currency: 'USD',
|
|
439
|
+
}).format(amount)
|
|
440
|
+
}
|
|
441
|
+
\`\`\`
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
And here is some text after the code block.",
|
|
445
|
+
]
|
|
446
|
+
`)
|
|
447
|
+
})
|