kimaki 0.4.21 → 0.4.23
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/dist/channel-management.js +92 -0
- package/dist/cli.js +10 -2
- package/dist/database.js +130 -0
- package/dist/discord-bot.js +381 -0
- package/dist/discord-utils.js +151 -0
- package/dist/discordBot.js +60 -31
- package/dist/escape-backticks.test.js +1 -1
- package/dist/fork.js +163 -0
- package/dist/format-tables.js +93 -0
- package/dist/format-tables.test.js +418 -0
- package/dist/interaction-handler.js +750 -0
- package/dist/markdown.js +3 -3
- package/dist/message-formatting.js +188 -0
- package/dist/model-command.js +293 -0
- package/dist/opencode.js +135 -0
- package/dist/session-handler.js +467 -0
- package/dist/system-message.js +92 -0
- package/dist/tools.js +3 -5
- package/dist/utils.js +31 -0
- package/dist/voice-handler.js +528 -0
- package/dist/voice.js +257 -35
- package/package.json +3 -2
- package/src/channel-management.ts +145 -0
- package/src/cli.ts +10 -2
- package/src/database.ts +155 -0
- package/src/discord-bot.ts +506 -0
- package/src/discord-utils.ts +208 -0
- package/src/escape-backticks.test.ts +1 -1
- package/src/fork.ts +224 -0
- package/src/format-tables.test.ts +440 -0
- package/src/format-tables.ts +106 -0
- package/src/interaction-handler.ts +1000 -0
- package/src/markdown.ts +3 -3
- package/src/message-formatting.ts +227 -0
- package/src/model-command.ts +380 -0
- package/src/opencode.ts +180 -0
- package/src/session-handler.ts +601 -0
- package/src/system-message.ts +92 -0
- package/src/tools.ts +3 -5
- package/src/utils.ts +37 -0
- package/src/voice-handler.ts +745 -0
- package/src/voice.ts +354 -36
- package/src/discordBot.ts +0 -3643
package/src/fork.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChatInputCommandInteraction,
|
|
3
|
+
StringSelectMenuInteraction,
|
|
4
|
+
StringSelectMenuBuilder,
|
|
5
|
+
ActionRowBuilder,
|
|
6
|
+
ChannelType,
|
|
7
|
+
ThreadAutoArchiveDuration,
|
|
8
|
+
type ThreadChannel,
|
|
9
|
+
} from 'discord.js'
|
|
10
|
+
import type { TextPart } from '@opencode-ai/sdk'
|
|
11
|
+
import { getDatabase } from './database.js'
|
|
12
|
+
import { initializeOpencodeForDirectory } from './opencode.js'
|
|
13
|
+
import { resolveTextChannel, getKimakiMetadata, sendThreadMessage } from './discord-utils.js'
|
|
14
|
+
import { createLogger } from './logger.js'
|
|
15
|
+
|
|
16
|
+
const sessionLogger = createLogger('SESSION')
|
|
17
|
+
const forkLogger = createLogger('FORK')
|
|
18
|
+
|
|
19
|
+
export async function handleForkCommand(interaction: ChatInputCommandInteraction): Promise<void> {
|
|
20
|
+
const channel = interaction.channel
|
|
21
|
+
|
|
22
|
+
if (!channel) {
|
|
23
|
+
await interaction.reply({
|
|
24
|
+
content: 'This command can only be used in a channel',
|
|
25
|
+
ephemeral: true,
|
|
26
|
+
})
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const isThread = [
|
|
31
|
+
ChannelType.PublicThread,
|
|
32
|
+
ChannelType.PrivateThread,
|
|
33
|
+
ChannelType.AnnouncementThread,
|
|
34
|
+
].includes(channel.type)
|
|
35
|
+
|
|
36
|
+
if (!isThread) {
|
|
37
|
+
await interaction.reply({
|
|
38
|
+
content: 'This command can only be used in a thread with an active session',
|
|
39
|
+
ephemeral: true,
|
|
40
|
+
})
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const textChannel = await resolveTextChannel(channel as ThreadChannel)
|
|
45
|
+
const { projectDirectory: directory } = getKimakiMetadata(textChannel)
|
|
46
|
+
|
|
47
|
+
if (!directory) {
|
|
48
|
+
await interaction.reply({
|
|
49
|
+
content: 'Could not determine project directory for this channel',
|
|
50
|
+
ephemeral: true,
|
|
51
|
+
})
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const row = getDatabase()
|
|
56
|
+
.prepare('SELECT session_id FROM thread_sessions WHERE thread_id = ?')
|
|
57
|
+
.get(channel.id) as { session_id: string } | undefined
|
|
58
|
+
|
|
59
|
+
if (!row?.session_id) {
|
|
60
|
+
await interaction.reply({
|
|
61
|
+
content: 'No active session in this thread',
|
|
62
|
+
ephemeral: true,
|
|
63
|
+
})
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Defer reply before API calls to avoid 3-second timeout
|
|
68
|
+
await interaction.deferReply({ ephemeral: true })
|
|
69
|
+
|
|
70
|
+
const sessionId = row.session_id
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const getClient = await initializeOpencodeForDirectory(directory)
|
|
74
|
+
|
|
75
|
+
const messagesResponse = await getClient().session.messages({
|
|
76
|
+
path: { id: sessionId },
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
if (!messagesResponse.data) {
|
|
80
|
+
await interaction.editReply({
|
|
81
|
+
content: 'Failed to fetch session messages',
|
|
82
|
+
})
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const userMessages = messagesResponse.data.filter(
|
|
87
|
+
(m) => m.info.role === 'user'
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if (userMessages.length === 0) {
|
|
91
|
+
await interaction.editReply({
|
|
92
|
+
content: 'No user messages found in this session',
|
|
93
|
+
})
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const recentMessages = userMessages.slice(-25)
|
|
98
|
+
|
|
99
|
+
const options = recentMessages.map((m, index) => {
|
|
100
|
+
const textPart = m.parts.find((p) => p.type === 'text') as TextPart | undefined
|
|
101
|
+
const preview = textPart?.text?.slice(0, 80) || '(no text)'
|
|
102
|
+
const label = `${index + 1}. ${preview}${preview.length >= 80 ? '...' : ''}`
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
label: label.slice(0, 100),
|
|
106
|
+
value: m.info.id,
|
|
107
|
+
description: new Date(m.info.time.created).toLocaleString().slice(0, 50),
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const encodedDir = Buffer.from(directory).toString('base64')
|
|
112
|
+
|
|
113
|
+
const selectMenu = new StringSelectMenuBuilder()
|
|
114
|
+
.setCustomId(`fork_select:${sessionId}:${encodedDir}`)
|
|
115
|
+
.setPlaceholder('Select a message to fork from')
|
|
116
|
+
.addOptions(options)
|
|
117
|
+
|
|
118
|
+
const actionRow = new ActionRowBuilder<StringSelectMenuBuilder>()
|
|
119
|
+
.addComponents(selectMenu)
|
|
120
|
+
|
|
121
|
+
await interaction.editReply({
|
|
122
|
+
content: '**Fork Session**\nSelect the user message to fork from. The forked session will continue as if you had not sent that message:',
|
|
123
|
+
components: [actionRow],
|
|
124
|
+
})
|
|
125
|
+
} catch (error) {
|
|
126
|
+
forkLogger.error('Error loading messages:', error)
|
|
127
|
+
await interaction.editReply({
|
|
128
|
+
content: `Failed to load messages: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function handleForkSelectMenu(interaction: StringSelectMenuInteraction): Promise<void> {
|
|
134
|
+
const customId = interaction.customId
|
|
135
|
+
|
|
136
|
+
if (!customId.startsWith('fork_select:')) {
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const [, sessionId, encodedDir] = customId.split(':')
|
|
141
|
+
if (!sessionId || !encodedDir) {
|
|
142
|
+
await interaction.reply({
|
|
143
|
+
content: 'Invalid selection data',
|
|
144
|
+
ephemeral: true,
|
|
145
|
+
})
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const directory = Buffer.from(encodedDir, 'base64').toString('utf-8')
|
|
150
|
+
const selectedMessageId = interaction.values[0]
|
|
151
|
+
|
|
152
|
+
if (!selectedMessageId) {
|
|
153
|
+
await interaction.reply({
|
|
154
|
+
content: 'No message selected',
|
|
155
|
+
ephemeral: true,
|
|
156
|
+
})
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
await interaction.deferReply({ ephemeral: false })
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const getClient = await initializeOpencodeForDirectory(directory)
|
|
164
|
+
|
|
165
|
+
const forkResponse = await getClient().session.fork({
|
|
166
|
+
path: { id: sessionId },
|
|
167
|
+
body: { messageID: selectedMessageId },
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
if (!forkResponse.data) {
|
|
171
|
+
await interaction.editReply('Failed to fork session')
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const forkedSession = forkResponse.data
|
|
176
|
+
const parentChannel = interaction.channel
|
|
177
|
+
|
|
178
|
+
if (!parentChannel || ![
|
|
179
|
+
ChannelType.PublicThread,
|
|
180
|
+
ChannelType.PrivateThread,
|
|
181
|
+
ChannelType.AnnouncementThread,
|
|
182
|
+
].includes(parentChannel.type)) {
|
|
183
|
+
await interaction.editReply('Could not access parent channel')
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const textChannel = await resolveTextChannel(parentChannel as ThreadChannel)
|
|
188
|
+
|
|
189
|
+
if (!textChannel) {
|
|
190
|
+
await interaction.editReply('Could not resolve parent text channel')
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const thread = await textChannel.threads.create({
|
|
195
|
+
name: `Fork: ${forkedSession.title}`.slice(0, 100),
|
|
196
|
+
autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
|
|
197
|
+
reason: `Forked from session ${sessionId}`,
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
getDatabase()
|
|
201
|
+
.prepare(
|
|
202
|
+
'INSERT OR REPLACE INTO thread_sessions (thread_id, session_id) VALUES (?, ?)'
|
|
203
|
+
)
|
|
204
|
+
.run(thread.id, forkedSession.id)
|
|
205
|
+
|
|
206
|
+
sessionLogger.log(
|
|
207
|
+
`Created forked session ${forkedSession.id} in thread ${thread.id}`
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
await sendThreadMessage(
|
|
211
|
+
thread,
|
|
212
|
+
`**Forked session created!**\nFrom: \`${sessionId}\`\nNew session: \`${forkedSession.id}\`\n\nYou can now continue the conversation from this point.`
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
await interaction.editReply(
|
|
216
|
+
`Session forked! Continue in ${thread.toString()}`
|
|
217
|
+
)
|
|
218
|
+
} catch (error) {
|
|
219
|
+
forkLogger.error('Error forking session:', error)
|
|
220
|
+
await interaction.editReply(
|
|
221
|
+
`Failed to fork session: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { test, expect } from 'vitest'
|
|
2
|
+
import { formatMarkdownTables } from './format-tables.js'
|
|
3
|
+
|
|
4
|
+
test('formats simple table', () => {
|
|
5
|
+
const input = `| Name | Age |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| Alice | 30 |
|
|
8
|
+
| Bob | 25 |`
|
|
9
|
+
const result = formatMarkdownTables(input)
|
|
10
|
+
expect(result).toMatchInlineSnapshot(`
|
|
11
|
+
"\`\`\`
|
|
12
|
+
Name Age
|
|
13
|
+
----- ---
|
|
14
|
+
Alice 30
|
|
15
|
+
Bob 25
|
|
16
|
+
\`\`\`
|
|
17
|
+
"
|
|
18
|
+
`)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('formats table with varying column widths', () => {
|
|
22
|
+
const input = `| Item | Quantity | Price |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| Apples | 10 | $5 |
|
|
25
|
+
| Oranges | 3 | $2 |
|
|
26
|
+
| Bananas with long name | 100 | $15.99 |`
|
|
27
|
+
const result = formatMarkdownTables(input)
|
|
28
|
+
expect(result).toMatchInlineSnapshot(`
|
|
29
|
+
"\`\`\`
|
|
30
|
+
Item Quantity Price
|
|
31
|
+
---------------------- -------- ------
|
|
32
|
+
Apples 10 $5
|
|
33
|
+
Oranges 3 $2
|
|
34
|
+
Bananas with long name 100 $15.99
|
|
35
|
+
\`\`\`
|
|
36
|
+
"
|
|
37
|
+
`)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('strips bold formatting from cells', () => {
|
|
41
|
+
const input = `| Header | Value |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| **Bold text** | Normal |
|
|
44
|
+
| Mixed **bold** text | Another |`
|
|
45
|
+
const result = formatMarkdownTables(input)
|
|
46
|
+
expect(result).toMatchInlineSnapshot(`
|
|
47
|
+
"\`\`\`
|
|
48
|
+
Header Value
|
|
49
|
+
--------------- -------
|
|
50
|
+
Bold text Normal
|
|
51
|
+
Mixed bold text Another
|
|
52
|
+
\`\`\`
|
|
53
|
+
"
|
|
54
|
+
`)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('strips italic formatting from cells', () => {
|
|
58
|
+
const input = `| Header | Value |
|
|
59
|
+
| --- | --- |
|
|
60
|
+
| *Italic text* | Normal |
|
|
61
|
+
| _Also italic_ | Another |`
|
|
62
|
+
const result = formatMarkdownTables(input)
|
|
63
|
+
expect(result).toMatchInlineSnapshot(`
|
|
64
|
+
"\`\`\`
|
|
65
|
+
Header Value
|
|
66
|
+
----------- -------
|
|
67
|
+
Italic text Normal
|
|
68
|
+
Also italic Another
|
|
69
|
+
\`\`\`
|
|
70
|
+
"
|
|
71
|
+
`)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('extracts URL from links', () => {
|
|
75
|
+
const input = `| Name | Link |
|
|
76
|
+
| --- | --- |
|
|
77
|
+
| Google | [Click here](https://google.com) |
|
|
78
|
+
| GitHub | [GitHub Home](https://github.com) |`
|
|
79
|
+
const result = formatMarkdownTables(input)
|
|
80
|
+
expect(result).toMatchInlineSnapshot(`
|
|
81
|
+
"\`\`\`
|
|
82
|
+
Name Link
|
|
83
|
+
------ ------------------
|
|
84
|
+
Google https://google.com
|
|
85
|
+
GitHub https://github.com
|
|
86
|
+
\`\`\`
|
|
87
|
+
"
|
|
88
|
+
`)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('handles inline code in cells', () => {
|
|
92
|
+
const input = `| Function | Description |
|
|
93
|
+
| --- | --- |
|
|
94
|
+
| \`console.log\` | Logs to console |
|
|
95
|
+
| \`Array.map\` | Maps array items |`
|
|
96
|
+
const result = formatMarkdownTables(input)
|
|
97
|
+
expect(result).toMatchInlineSnapshot(`
|
|
98
|
+
"\`\`\`
|
|
99
|
+
Function Description
|
|
100
|
+
----------- ----------------
|
|
101
|
+
console.log Logs to console
|
|
102
|
+
Array.map Maps array items
|
|
103
|
+
\`\`\`
|
|
104
|
+
"
|
|
105
|
+
`)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('handles mixed formatting in single cell', () => {
|
|
109
|
+
const input = `| Description |
|
|
110
|
+
| --- |
|
|
111
|
+
| This has **bold**, *italic*, and \`code\` |
|
|
112
|
+
| Also [a link](https://example.com) here |`
|
|
113
|
+
const result = formatMarkdownTables(input)
|
|
114
|
+
expect(result).toMatchInlineSnapshot(`
|
|
115
|
+
"\`\`\`
|
|
116
|
+
Description
|
|
117
|
+
-------------------------------
|
|
118
|
+
This has bold, italic, and code
|
|
119
|
+
Also https://example.com here
|
|
120
|
+
\`\`\`
|
|
121
|
+
"
|
|
122
|
+
`)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
test('handles strikethrough text', () => {
|
|
126
|
+
const input = `| Status | Item |
|
|
127
|
+
| --- | --- |
|
|
128
|
+
| Done | ~~Deleted item~~ |
|
|
129
|
+
| Active | Normal item |`
|
|
130
|
+
const result = formatMarkdownTables(input)
|
|
131
|
+
expect(result).toMatchInlineSnapshot(`
|
|
132
|
+
"\`\`\`
|
|
133
|
+
Status Item
|
|
134
|
+
------ ------------
|
|
135
|
+
Done Deleted item
|
|
136
|
+
Active Normal item
|
|
137
|
+
\`\`\`
|
|
138
|
+
"
|
|
139
|
+
`)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
test('preserves content before table', () => {
|
|
143
|
+
const input = `Here is some text before the table.
|
|
144
|
+
|
|
145
|
+
| Col A | Col B |
|
|
146
|
+
| --- | --- |
|
|
147
|
+
| 1 | 2 |`
|
|
148
|
+
const result = formatMarkdownTables(input)
|
|
149
|
+
expect(result).toMatchInlineSnapshot(`
|
|
150
|
+
"Here is some text before the table.
|
|
151
|
+
|
|
152
|
+
\`\`\`
|
|
153
|
+
Col A Col B
|
|
154
|
+
----- -----
|
|
155
|
+
1 2
|
|
156
|
+
\`\`\`
|
|
157
|
+
"
|
|
158
|
+
`)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test('preserves content after table', () => {
|
|
162
|
+
const input = `| Col A | Col B |
|
|
163
|
+
| --- | --- |
|
|
164
|
+
| 1 | 2 |
|
|
165
|
+
|
|
166
|
+
And here is text after.`
|
|
167
|
+
const result = formatMarkdownTables(input)
|
|
168
|
+
expect(result).toMatchInlineSnapshot(`
|
|
169
|
+
"\`\`\`
|
|
170
|
+
Col A Col B
|
|
171
|
+
----- -----
|
|
172
|
+
1 2
|
|
173
|
+
\`\`\`
|
|
174
|
+
And here is text after."
|
|
175
|
+
`)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test('preserves content before and after table', () => {
|
|
179
|
+
const input = `Some intro text.
|
|
180
|
+
|
|
181
|
+
| Name | Value |
|
|
182
|
+
| --- | --- |
|
|
183
|
+
| Key | 123 |
|
|
184
|
+
|
|
185
|
+
Some outro text.`
|
|
186
|
+
const result = formatMarkdownTables(input)
|
|
187
|
+
expect(result).toMatchInlineSnapshot(`
|
|
188
|
+
"Some intro text.
|
|
189
|
+
|
|
190
|
+
\`\`\`
|
|
191
|
+
Name Value
|
|
192
|
+
---- -----
|
|
193
|
+
Key 123
|
|
194
|
+
\`\`\`
|
|
195
|
+
Some outro text."
|
|
196
|
+
`)
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
test('handles multiple tables in same content', () => {
|
|
200
|
+
const input = `First table:
|
|
201
|
+
|
|
202
|
+
| A | B |
|
|
203
|
+
| --- | --- |
|
|
204
|
+
| 1 | 2 |
|
|
205
|
+
|
|
206
|
+
Some text between.
|
|
207
|
+
|
|
208
|
+
Second table:
|
|
209
|
+
|
|
210
|
+
| X | Y | Z |
|
|
211
|
+
| --- | --- | --- |
|
|
212
|
+
| a | b | c |`
|
|
213
|
+
const result = formatMarkdownTables(input)
|
|
214
|
+
expect(result).toMatchInlineSnapshot(`
|
|
215
|
+
"First table:
|
|
216
|
+
|
|
217
|
+
\`\`\`
|
|
218
|
+
A B
|
|
219
|
+
- -
|
|
220
|
+
1 2
|
|
221
|
+
\`\`\`
|
|
222
|
+
Some text between.
|
|
223
|
+
|
|
224
|
+
Second table:
|
|
225
|
+
|
|
226
|
+
\`\`\`
|
|
227
|
+
X Y Z
|
|
228
|
+
- - -
|
|
229
|
+
a b c
|
|
230
|
+
\`\`\`
|
|
231
|
+
"
|
|
232
|
+
`)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('handles empty cells', () => {
|
|
236
|
+
const input = `| Name | Optional |
|
|
237
|
+
| --- | --- |
|
|
238
|
+
| Alice | |
|
|
239
|
+
| | Bob |
|
|
240
|
+
| | |`
|
|
241
|
+
const result = formatMarkdownTables(input)
|
|
242
|
+
expect(result).toMatchInlineSnapshot(`
|
|
243
|
+
"\`\`\`
|
|
244
|
+
Name Optional
|
|
245
|
+
----- --------
|
|
246
|
+
Alice
|
|
247
|
+
Bob
|
|
248
|
+
|
|
249
|
+
\`\`\`
|
|
250
|
+
"
|
|
251
|
+
`)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
test('handles single column table', () => {
|
|
255
|
+
const input = `| Items |
|
|
256
|
+
| --- |
|
|
257
|
+
| Apple |
|
|
258
|
+
| Banana |
|
|
259
|
+
| Cherry |`
|
|
260
|
+
const result = formatMarkdownTables(input)
|
|
261
|
+
expect(result).toMatchInlineSnapshot(`
|
|
262
|
+
"\`\`\`
|
|
263
|
+
Items
|
|
264
|
+
------
|
|
265
|
+
Apple
|
|
266
|
+
Banana
|
|
267
|
+
Cherry
|
|
268
|
+
\`\`\`
|
|
269
|
+
"
|
|
270
|
+
`)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test('handles single row table', () => {
|
|
274
|
+
const input = `| A | B | C | D |
|
|
275
|
+
| --- | --- | --- | --- |
|
|
276
|
+
| 1 | 2 | 3 | 4 |`
|
|
277
|
+
const result = formatMarkdownTables(input)
|
|
278
|
+
expect(result).toMatchInlineSnapshot(`
|
|
279
|
+
"\`\`\`
|
|
280
|
+
A B C D
|
|
281
|
+
- - - -
|
|
282
|
+
1 2 3 4
|
|
283
|
+
\`\`\`
|
|
284
|
+
"
|
|
285
|
+
`)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
test('handles nested formatting', () => {
|
|
289
|
+
const input = `| Description |
|
|
290
|
+
| --- |
|
|
291
|
+
| **Bold with *nested italic* inside** |
|
|
292
|
+
| *Italic with **nested bold** inside* |`
|
|
293
|
+
const result = formatMarkdownTables(input)
|
|
294
|
+
expect(result).toMatchInlineSnapshot(`
|
|
295
|
+
"\`\`\`
|
|
296
|
+
Description
|
|
297
|
+
------------------------------
|
|
298
|
+
Bold with nested italic inside
|
|
299
|
+
Italic with nested bold inside
|
|
300
|
+
\`\`\`
|
|
301
|
+
"
|
|
302
|
+
`)
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
test('handles image references', () => {
|
|
306
|
+
const input = `| Icon | Name |
|
|
307
|
+
| --- | --- |
|
|
308
|
+
|  | Item 1 |
|
|
309
|
+
|  | Item 2 |`
|
|
310
|
+
const result = formatMarkdownTables(input)
|
|
311
|
+
expect(result).toMatchInlineSnapshot(`
|
|
312
|
+
"\`\`\`
|
|
313
|
+
Icon Name
|
|
314
|
+
---------------------------- ------
|
|
315
|
+
https://example.com/icon.png Item 1
|
|
316
|
+
https://cdn.test.com/img.jpg Item 2
|
|
317
|
+
\`\`\`
|
|
318
|
+
"
|
|
319
|
+
`)
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
test('preserves code blocks alongside tables', () => {
|
|
323
|
+
const input = `Some code:
|
|
324
|
+
|
|
325
|
+
\`\`\`js
|
|
326
|
+
const x = 1
|
|
327
|
+
\`\`\`
|
|
328
|
+
|
|
329
|
+
A table:
|
|
330
|
+
|
|
331
|
+
| Key | Value |
|
|
332
|
+
| --- | --- |
|
|
333
|
+
| a | 1 |
|
|
334
|
+
|
|
335
|
+
More code:
|
|
336
|
+
|
|
337
|
+
\`\`\`python
|
|
338
|
+
print("hello")
|
|
339
|
+
\`\`\``
|
|
340
|
+
const result = formatMarkdownTables(input)
|
|
341
|
+
expect(result).toMatchInlineSnapshot(`
|
|
342
|
+
"Some code:
|
|
343
|
+
|
|
344
|
+
\`\`\`js
|
|
345
|
+
const x = 1
|
|
346
|
+
\`\`\`
|
|
347
|
+
|
|
348
|
+
A table:
|
|
349
|
+
|
|
350
|
+
\`\`\`
|
|
351
|
+
Key Value
|
|
352
|
+
--- -----
|
|
353
|
+
a 1
|
|
354
|
+
\`\`\`
|
|
355
|
+
More code:
|
|
356
|
+
|
|
357
|
+
\`\`\`python
|
|
358
|
+
print("hello")
|
|
359
|
+
\`\`\`"
|
|
360
|
+
`)
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
test('handles content without tables', () => {
|
|
364
|
+
const input = `Just some regular markdown.
|
|
365
|
+
|
|
366
|
+
- List item 1
|
|
367
|
+
- List item 2
|
|
368
|
+
|
|
369
|
+
**Bold text** and *italic*.`
|
|
370
|
+
const result = formatMarkdownTables(input)
|
|
371
|
+
expect(result).toMatchInlineSnapshot(`
|
|
372
|
+
"Just some regular markdown.
|
|
373
|
+
|
|
374
|
+
- List item 1
|
|
375
|
+
- List item 2
|
|
376
|
+
|
|
377
|
+
**Bold text** and *italic*."
|
|
378
|
+
`)
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
test('handles complex real-world table', () => {
|
|
382
|
+
const input = `## API Endpoints
|
|
383
|
+
|
|
384
|
+
| Method | Endpoint | Description | Auth |
|
|
385
|
+
| --- | --- | --- | --- |
|
|
386
|
+
| GET | \`/api/users\` | List all users | [Bearer token](https://docs.example.com/auth) |
|
|
387
|
+
| POST | \`/api/users\` | Create **new** user | Required |
|
|
388
|
+
| DELETE | \`/api/users/:id\` | ~~Remove~~ *Deactivate* user | Admin only |`
|
|
389
|
+
const result = formatMarkdownTables(input)
|
|
390
|
+
expect(result).toMatchInlineSnapshot(`
|
|
391
|
+
"## API Endpoints
|
|
392
|
+
|
|
393
|
+
\`\`\`
|
|
394
|
+
Method Endpoint Description Auth
|
|
395
|
+
------ -------------- ---------------------- -----------------------------
|
|
396
|
+
GET /api/users List all users https://docs.example.com/auth
|
|
397
|
+
POST /api/users Create new user Required
|
|
398
|
+
DELETE /api/users/:id Remove Deactivate user Admin only
|
|
399
|
+
\`\`\`
|
|
400
|
+
"
|
|
401
|
+
`)
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
test('handles unicode content', () => {
|
|
405
|
+
const input = `| Emoji | Name | Country |
|
|
406
|
+
| --- | --- | --- |
|
|
407
|
+
| 🍎 | Apple | 日本 |
|
|
408
|
+
| 🍊 | Orange | España |
|
|
409
|
+
| 🍌 | Banana | Ελλάδα |`
|
|
410
|
+
const result = formatMarkdownTables(input)
|
|
411
|
+
expect(result).toMatchInlineSnapshot(`
|
|
412
|
+
"\`\`\`
|
|
413
|
+
Emoji Name Country
|
|
414
|
+
----- ------ -------
|
|
415
|
+
🍎 Apple 日本
|
|
416
|
+
🍊 Orange España
|
|
417
|
+
🍌 Banana Ελλάδα
|
|
418
|
+
\`\`\`
|
|
419
|
+
"
|
|
420
|
+
`)
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
test('handles numbers and special characters', () => {
|
|
424
|
+
const input = `| Price | Discount | Final |
|
|
425
|
+
| --- | --- | --- |
|
|
426
|
+
| $100.00 | -15% | $85.00 |
|
|
427
|
+
| €50,00 | -10% | €45,00 |
|
|
428
|
+
| £75.99 | N/A | £75.99 |`
|
|
429
|
+
const result = formatMarkdownTables(input)
|
|
430
|
+
expect(result).toMatchInlineSnapshot(`
|
|
431
|
+
"\`\`\`
|
|
432
|
+
Price Discount Final
|
|
433
|
+
------- -------- ------
|
|
434
|
+
$100.00 -15% $85.00
|
|
435
|
+
€50,00 -10% €45,00
|
|
436
|
+
£75.99 N/A £75.99
|
|
437
|
+
\`\`\`
|
|
438
|
+
"
|
|
439
|
+
`)
|
|
440
|
+
})
|