@circlesac/mack 26.2.0 → 26.2.2

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.
@@ -9,6 +9,7 @@ import {
9
9
  RichTextBlock,
10
10
  RichTextElement,
11
11
  RichTextSectionElement,
12
+ RichTextStyle,
12
13
  richTextCode,
13
14
  richTextList,
14
15
  richTextQuote,
@@ -33,9 +34,18 @@ type PhrasingToken =
33
34
  | marked.Tokens.Codespan
34
35
  | marked.Tokens.Text
35
36
  | marked.Tokens.HTML
37
+ | marked.Tokens.Escape
36
38
 
37
39
  let recursionDepth = 0
38
40
 
41
+ /**
42
+ * Escapes &, <, > for Slack mrkdwn format.
43
+ * Only used in the section/mrkdwn code path (not rich_text).
44
+ */
45
+ function escapeForMrkdwn(text: string): string {
46
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
47
+ }
48
+
39
49
  function parsePlainText(element: PhrasingToken): string[] {
40
50
  switch (element.type) {
41
51
  case "link":
@@ -80,14 +90,14 @@ function parseMrkdwn(element: Exclude<PhrasingToken, marked.Tokens.Image>): stri
80
90
  }
81
91
 
82
92
  case "codespan":
83
- return `\`${element.text}\``
93
+ return `\`${escapeForMrkdwn(element.text)}\``
84
94
 
85
95
  case "strong": {
86
96
  return `*${element.tokens.flatMap((child) => parseMrkdwn(child as Exclude<PhrasingToken, marked.Tokens.Image>)).join("")}*`
87
97
  }
88
98
 
89
99
  case "text":
90
- return element.text
100
+ return escapeForMrkdwn(element.text)
91
101
 
92
102
  case "del": {
93
103
  return `~${element.tokens.flatMap((child) => parseMrkdwn(child as Exclude<PhrasingToken, marked.Tokens.Image>)).join("")}~`
@@ -139,16 +149,19 @@ function parseCode(element: marked.Tokens.Code): RichTextBlock {
139
149
  return richTextCode(element.text)
140
150
  }
141
151
 
152
+ // --- Rich text helpers ---
153
+
154
+ function hasStyle(style?: RichTextStyle): boolean {
155
+ return style !== undefined && Object.keys(style).length > 0
156
+ }
157
+
142
158
  /**
143
159
  * Parses Slack special formatting patterns in text and returns rich text elements.
144
160
  * Handles: <@USER_ID>, <#CHANNEL_ID>, <!here>, <!channel>, <!everyone>,
145
161
  * <!subteam^TEAM_ID>, <!date^timestamp^format|fallback>, <url|text>
146
- *
147
- * Note: The tokenizer escapes < > & to &lt; &gt; &amp;, so we match escaped forms.
148
162
  */
149
- function parseSlackSpecialFormatting(text: string): RichTextSectionElement[] {
150
- // Match escaped angle brackets: &lt;...&gt;
151
- const slackPattern = /&lt;(@[A-Z0-9]+(?:\|[^&]+)?|#[A-Z0-9]+(?:\|[^&]+)?|![a-z]+(?:\^[^&]+)*(?:\|[^&]+)?|https?:\/\/[^|&]+\|[^&]+|https?:\/\/[^&]+)&gt;/g
163
+ function parseSlackSpecialFormatting(text: string, style?: RichTextStyle): RichTextSectionElement[] {
164
+ const slackPattern = /<(@[A-Z0-9]+(?:\|[^>]+)?|#[A-Z0-9]+(?:\|[^>]+)?|![a-z]+(?:\^[^>]+)*(?:\|[^>]+)?|https?:\/\/[^|>]+\|[^>]+|https?:\/\/[^>]+)>/g
152
165
 
153
166
  const elements: RichTextSectionElement[] = []
154
167
  let lastIndex = 0
@@ -156,27 +169,28 @@ function parseSlackSpecialFormatting(text: string): RichTextSectionElement[] {
156
169
 
157
170
  while ((match = slackPattern.exec(text)) !== null) {
158
171
  if (match.index > lastIndex) {
159
- elements.push({ type: "text", text: text.slice(lastIndex, match.index) })
172
+ const beforeText = text.slice(lastIndex, match.index)
173
+ elements.push({ type: "text", text: beforeText, ...(hasStyle(style) && { style }) })
160
174
  }
161
175
 
162
176
  const content = match[1]
163
177
 
164
178
  if (content.startsWith("@")) {
165
179
  const [userId] = content.slice(1).split("|")
166
- elements.push({ type: "user", user_id: userId })
180
+ elements.push({ type: "user", user_id: userId, ...(hasStyle(style) && { style }) })
167
181
  } else if (content.startsWith("#")) {
168
182
  const [channelId] = content.slice(1).split("|")
169
- elements.push({ type: "channel", channel_id: channelId })
183
+ elements.push({ type: "channel", channel_id: channelId, ...(hasStyle(style) && { style }) })
170
184
  } else if (content.startsWith("!")) {
171
185
  if (content === "!here") {
172
- elements.push({ type: "broadcast", range: "here" })
186
+ elements.push({ type: "broadcast", range: "here", ...(hasStyle(style) && { style }) })
173
187
  } else if (content === "!channel") {
174
- elements.push({ type: "broadcast", range: "channel" })
188
+ elements.push({ type: "broadcast", range: "channel", ...(hasStyle(style) && { style }) })
175
189
  } else if (content === "!everyone") {
176
- elements.push({ type: "broadcast", range: "everyone" })
190
+ elements.push({ type: "broadcast", range: "everyone", ...(hasStyle(style) && { style }) })
177
191
  } else if (content.startsWith("!subteam^")) {
178
192
  const usergroupId = content.slice(9)
179
- elements.push({ type: "usergroup", usergroup_id: usergroupId })
193
+ elements.push({ type: "usergroup", usergroup_id: usergroupId, ...(hasStyle(style) && { style }) })
180
194
  } else if (content.startsWith("!date^")) {
181
195
  const dateContent = content.slice(6)
182
196
  const parts = dateContent.split("|")
@@ -188,10 +202,11 @@ function parseSlackSpecialFormatting(text: string): RichTextSectionElement[] {
188
202
  type: "date",
189
203
  timestamp,
190
204
  format,
191
- ...(fallback && { fallback })
205
+ ...(fallback && { fallback }),
206
+ ...(hasStyle(style) && { style })
192
207
  })
193
208
  } else {
194
- elements.push({ type: "text", text: match[0] })
209
+ elements.push({ type: "text", text: match[0], ...(hasStyle(style) && { style }) })
195
210
  }
196
211
  } else if (content.startsWith("http://") || content.startsWith("https://")) {
197
212
  const pipeIndex = content.indexOf("|")
@@ -199,167 +214,414 @@ function parseSlackSpecialFormatting(text: string): RichTextSectionElement[] {
199
214
  elements.push({
200
215
  type: "link",
201
216
  url: content.slice(0, pipeIndex),
202
- text: content.slice(pipeIndex + 1)
217
+ text: content.slice(pipeIndex + 1),
218
+ ...(hasStyle(style) && { style })
203
219
  })
204
220
  } else {
205
- elements.push({ type: "link", url: content })
221
+ elements.push({ type: "link", url: content, ...(hasStyle(style) && { style }) })
206
222
  }
207
223
  } else {
208
- elements.push({ type: "text", text: match[0] })
224
+ elements.push({ type: "text", text: match[0], ...(hasStyle(style) && { style }) })
209
225
  }
210
226
 
211
227
  lastIndex = match.index + match[0].length
212
228
  }
213
229
 
214
230
  if (lastIndex < text.length) {
215
- elements.push({ type: "text", text: text.slice(lastIndex) })
231
+ const afterText = text.slice(lastIndex)
232
+ elements.push({ type: "text", text: afterText, ...(hasStyle(style) && { style }) })
216
233
  }
217
234
 
218
235
  if (elements.length === 0) {
219
- return [{ type: "text", text }]
236
+ return [{ type: "text", text, ...(hasStyle(style) && { style }) }]
220
237
  }
221
238
 
222
239
  return elements
223
240
  }
224
241
 
225
242
  /**
226
- * Converts inline marked tokens to RichTextSectionElements for use in
227
- * rich_text_list items, table cells, and blockquotes.
243
+ * Creates a text element with Slack special formatting parsing.
244
+ * Applies soft line break conversion (single \n → space).
228
245
  */
229
- function tokensToRichTextElements(tokens: PhrasingToken[]): RichTextSectionElement[] {
230
- const elements: RichTextSectionElement[] = []
246
+ function createTextElement(token: marked.Tokens.Text | marked.Tokens.Codespan, style?: RichTextStyle): RichTextSectionElement[] {
247
+ // Soft line breaks: convert single newlines to spaces (standard markdown behavior)
248
+ const text = token.text.replace(/\n(?!\n)/g, " ")
231
249
 
232
- for (const token of tokens) {
233
- switch (token.type) {
234
- case "text":
235
- elements.push(...parseSlackSpecialFormatting(token.text))
236
- break
250
+ // Code spans: don't parse Slack formatting (keep as literal text)
251
+ if (token.type === "codespan") {
252
+ return [{ type: "text", text, ...(hasStyle(style) && { style }) }]
253
+ }
237
254
 
238
- case "strong": {
239
- const strongText = token.tokens.map((t) => (t as marked.Tokens.Text).text || "").join("")
240
- elements.push({ type: "text", text: strongText, style: { bold: true } })
241
- break
242
- }
255
+ return parseSlackSpecialFormatting(text, style)
256
+ }
243
257
 
244
- case "em": {
245
- const emText = token.tokens.map((t) => (t as marked.Tokens.Text).text || "").join("")
246
- elements.push({ type: "text", text: emText, style: { italic: true } })
247
- break
248
- }
258
+ /**
259
+ * Creates link elements with style propagation and Slack pipe format handling.
260
+ */
261
+ function createLinkElements(token: marked.Tokens.Link, childTokens: PhrasingToken[], baseStyle?: RichTextStyle): RichTextSectionElement[] {
262
+ const href = token.href
263
+
264
+ // Check for Slack pipe format in href
265
+ const pipeIndex = href.indexOf("|")
266
+ if (pipeIndex !== -1 && pipeIndex > 0) {
267
+ const url = href.slice(0, pipeIndex)
268
+ const linkText = href.slice(pipeIndex + 1)
269
+ return [{ type: "link", url, text: linkText, ...(hasStyle(baseStyle) && { style: baseStyle }) }]
270
+ }
249
271
 
250
- case "del": {
251
- const delText = token.tokens.map((t) => (t as marked.Tokens.Text).text || "").join("")
252
- elements.push({ type: "text", text: delText, style: { strike: true } })
253
- break
272
+ if (!validateUrl(href)) {
273
+ return processTokensWithStyle(childTokens, baseStyle)
274
+ }
275
+
276
+ // Process child tokens recursively for styled link text
277
+ const elements: RichTextSectionElement[] = processTokensWithStyle(childTokens, baseStyle)
278
+ .map((el) => {
279
+ if (el.type === "link") {
280
+ return { type: "text" as const, text: el.text || el.url || "", ...(el.style && { style: el.style }) }
254
281
  }
282
+ return el
283
+ })
284
+ .filter((el) => el.type === "text")
285
+
286
+ // Group consecutive text elements with the same style into single link elements
287
+ const linkElements: RichTextSectionElement[] = []
288
+ let currentText = ""
289
+ let currentStyle: RichTextStyle | undefined = baseStyle
290
+
291
+ for (const el of elements) {
292
+ if (el.type === "text") {
293
+ if (JSON.stringify(el.style) === JSON.stringify(currentStyle)) {
294
+ currentText += el.text
295
+ } else {
296
+ if (currentText) {
297
+ linkElements.push({
298
+ type: "link",
299
+ url: href,
300
+ ...(currentText !== href && { text: currentText }),
301
+ ...(hasStyle(currentStyle) && { style: currentStyle })
302
+ })
303
+ }
304
+ currentText = el.text
305
+ currentStyle = el.style
306
+ }
307
+ }
308
+ }
255
309
 
256
- case "codespan":
257
- elements.push({ type: "text", text: token.text, style: { code: true } })
258
- break
310
+ if (currentText) {
311
+ linkElements.push({
312
+ type: "link",
313
+ url: href,
314
+ ...(currentText !== href && { text: currentText }),
315
+ ...(hasStyle(currentStyle) && { style: currentStyle })
316
+ })
317
+ }
259
318
 
260
- case "link": {
261
- const linkText = token.tokens.map((t) => (t as marked.Tokens.Text).text || "").join("")
262
- if (validateUrl(token.href)) {
263
- elements.push({ type: "link", text: linkText, url: token.href })
264
- } else {
265
- elements.push({ type: "text", text: linkText })
319
+ if (linkElements.length === 0) {
320
+ const fallbackText = token.text || ""
321
+ linkElements.push({
322
+ type: "link",
323
+ url: href,
324
+ ...(fallbackText && fallbackText !== href && { text: fallbackText }),
325
+ ...(hasStyle(baseStyle) && { style: baseStyle })
326
+ })
327
+ }
328
+
329
+ return linkElements
330
+ }
331
+
332
+ /**
333
+ * Recursively converts a single inline token to RichTextSectionElements,
334
+ * accumulating style state through nesting.
335
+ */
336
+ function tokenToRichTextElements(token: PhrasingToken, style?: RichTextStyle): RichTextSectionElement[] {
337
+ const hasTokens = "tokens" in token && token.tokens && token.tokens.length > 0
338
+
339
+ // Leaf nodes: no nested tokens
340
+ if (!hasTokens) {
341
+ switch (token.type) {
342
+ case "text":
343
+ return createTextElement(token, style)
344
+ case "codespan":
345
+ return createTextElement(token, { ...style, code: true })
346
+ case "br":
347
+ return [{ type: "text", text: "\n", ...(hasStyle(style) && { style }) }]
348
+ case "escape": {
349
+ const escapeToken = token as marked.Tokens.Escape
350
+ // Use raw (minus backslash) because marked HTML-escapes escape token text
351
+ const escapedChar = escapeToken.raw.length > 1 ? escapeToken.raw.slice(1) : escapeToken.text
352
+ return [{ type: "text", text: escapedChar || "", ...(hasStyle(style) && { style }) }]
353
+ }
354
+ case "html": {
355
+ const htmlToken = token as marked.Tokens.HTML
356
+ const trimmedHtml = htmlToken.text.trim().toLowerCase()
357
+ if (trimmedHtml === "<br>" || trimmedHtml === "<br/>" || trimmedHtml === "<br />") {
358
+ return [{ type: "text", text: "\n", ...(hasStyle(style) && { style }) }]
266
359
  }
267
- break
360
+ // Check for Slack special patterns in HTML tokens
361
+ const slackElements = parseRawSlackSpecial(htmlToken.raw, style)
362
+ if (slackElements) return slackElements
363
+ return []
268
364
  }
269
-
270
365
  case "image":
271
- elements.push({
272
- type: "text",
273
- text: token.text || token.title || "[image]"
274
- })
275
- break
276
-
366
+ return [{ type: "text", text: token.text || token.title || "[image]" }]
277
367
  default:
278
368
  if ("text" in token && typeof (token as { text?: string }).text === "string") {
279
- elements.push({ type: "text", text: (token as { text: string }).text })
369
+ return [{ type: "text", text: (token as { text: string }).text, ...(hasStyle(style) && { style }) }]
280
370
  }
371
+ return []
281
372
  }
282
373
  }
283
374
 
375
+ // Recursive cases: tokens with children
376
+ const childTokens = (token as { tokens: PhrasingToken[] }).tokens
377
+ switch (token.type) {
378
+ case "text":
379
+ return processTokensWithStyle(childTokens, style)
380
+ case "strong":
381
+ return processTokensWithStyle(childTokens, { ...style, bold: true })
382
+ case "em":
383
+ return processTokensWithStyle(childTokens, { ...style, italic: true })
384
+ case "del":
385
+ return processTokensWithStyle(childTokens, { ...style, strike: true })
386
+ case "link":
387
+ return createLinkElements(token as marked.Tokens.Link, childTokens, style)
388
+ default:
389
+ return processTokensWithStyle(childTokens, style)
390
+ }
391
+ }
392
+
393
+ function processTokensWithStyle(tokens: PhrasingToken[], style?: RichTextStyle): RichTextSectionElement[] {
394
+ const elements: RichTextSectionElement[] = []
395
+ for (const token of tokens) {
396
+ elements.push(...tokenToRichTextElements(token, style))
397
+ }
284
398
  return elements
285
399
  }
286
400
 
287
401
  /**
288
- * Parses raw (unescaped) HTML tokens that may be Slack special patterns.
402
+ * Converts inline marked tokens to RichTextSectionElements for use in
403
+ * rich_text_list items, table cells, and blockquotes.
404
+ */
405
+ function tokensToRichTextElements(tokens: PhrasingToken[]): RichTextSectionElement[] {
406
+ return processTokensWithStyle(tokens)
407
+ }
408
+
409
+ /**
410
+ * Parses raw HTML tokens that may be Slack special patterns.
289
411
  * marked parses patterns like <!here>, <!channel>, <!everyone> as HTML
290
412
  * declarations, so they appear as html tokens rather than text tokens.
291
413
  */
292
- function parseRawSlackSpecial(raw: string): RichTextSectionElement[] | null {
414
+ function parseRawSlackSpecial(raw: string, style?: RichTextStyle): RichTextSectionElement[] | null {
293
415
  const trimmed = raw.trim()
294
416
  const match = trimmed.match(/^<(!(?:here|channel|everyone)|!subteam\^[A-Z0-9]+|@[A-Z0-9]+|#[A-Z0-9]+)>$/)
295
417
  if (!match) return null
296
418
 
297
419
  const content = match[1]
298
- if (content === "!here") return [{ type: "broadcast", range: "here" }]
299
- if (content === "!channel") return [{ type: "broadcast", range: "channel" }]
300
- if (content === "!everyone") return [{ type: "broadcast", range: "everyone" }]
420
+ if (content === "!here") return [{ type: "broadcast", range: "here", ...(hasStyle(style) && { style }) }]
421
+ if (content === "!channel") return [{ type: "broadcast", range: "channel", ...(hasStyle(style) && { style }) }]
422
+ if (content === "!everyone") return [{ type: "broadcast", range: "everyone", ...(hasStyle(style) && { style }) }]
301
423
  if (content.startsWith("!subteam^")) {
302
- return [{ type: "usergroup", usergroup_id: content.slice(9) }]
424
+ return [{ type: "usergroup", usergroup_id: content.slice(9), ...(hasStyle(style) && { style }) }]
303
425
  }
304
426
  if (content.startsWith("@")) {
305
- return [{ type: "user", user_id: content.slice(1) }]
427
+ return [{ type: "user", user_id: content.slice(1), ...(hasStyle(style) && { style }) }]
306
428
  }
307
429
  if (content.startsWith("#")) {
308
- return [{ type: "channel", channel_id: content.slice(1) }]
430
+ return [{ type: "channel", channel_id: content.slice(1), ...(hasStyle(style) && { style }) }]
309
431
  }
310
432
  return null
311
433
  }
312
434
 
313
- function parseList(element: marked.Tokens.List, options: ListOptions = {}, indent = 0): RichTextBlock {
314
- const items: RichTextElement[] = []
435
+ /** Inline token types that can appear in simple list items */
436
+ const INLINE_TOKEN_TYPES = new Set(["paragraph", "text", "html"])
437
+
438
+ /** Block-level token types that make a list item complex */
439
+ const BLOCK_TOKEN_TYPES = new Set(["code", "list", "blockquote", "table"])
440
+
441
+ /**
442
+ * Checks if a list item is "simple" (only inline content, no block-level elements).
443
+ * Simple items can be grouped together in a single rich_text_list block.
444
+ */
445
+ function isSimpleListItem(tokens: marked.Token[]): boolean {
446
+ return tokens.length > 0 && tokens.every((t) => !BLOCK_TOKEN_TYPES.has(t.type))
447
+ }
448
+
449
+ /**
450
+ * Gets the inline rich text elements from all inline tokens in a list item.
451
+ * When multiple paragraphs are present, inserts \n separators between them.
452
+ */
453
+ function getInlineElements(tokens: marked.Token[]): RichTextSectionElement[] {
454
+ const elements: RichTextSectionElement[] = []
455
+ let blockCount = 0
456
+ for (const token of tokens) {
457
+ if (token.type === "space") continue
458
+ if (token.type === "paragraph") {
459
+ if (blockCount > 0) {
460
+ elements.push({ type: "text", text: "\n" })
461
+ }
462
+ const para = token as marked.Tokens.Paragraph
463
+ elements.push(...tokensToRichTextElements(para.tokens as PhrasingToken[]))
464
+ blockCount++
465
+ } else if (token.type === "text") {
466
+ if (blockCount > 0) {
467
+ elements.push({ type: "text", text: "\n" })
468
+ }
469
+ const textToken = token as marked.Tokens.Text
470
+ if (textToken.tokens?.length) {
471
+ elements.push(...tokensToRichTextElements(textToken.tokens as PhrasingToken[]))
472
+ } else {
473
+ elements.push({ type: "text", text: textToken.text })
474
+ }
475
+ blockCount++
476
+ } else if (token.type === "html") {
477
+ const htmlToken = token as marked.Tokens.HTML
478
+ const slackElements = parseRawSlackSpecial(htmlToken.raw)
479
+ if (slackElements) {
480
+ elements.push(...slackElements)
481
+ }
482
+ }
483
+ }
484
+ return elements
485
+ }
486
+
487
+ function parseList(element: marked.Tokens.List, options: ListOptions = {}, indent = 0): (KnownBlock | RichTextBlock)[] {
488
+ const blocks: (KnownBlock | RichTextBlock)[] = []
489
+ const listStyle = element.ordered ? "ordered" : "bullet"
315
490
 
316
491
  const defaultCheckboxPrefix = (checked: boolean): string => {
317
492
  return checked ? "\u2611 " : "\u2610 "
318
493
  }
319
-
320
494
  const checkboxPrefix = options.checkboxPrefix || defaultCheckboxPrefix
321
495
 
496
+ // Track items for grouping and offset calculation
497
+ let currentSimpleItems: RichTextElement[] = []
498
+ let lastItemNumber = 0
499
+ const startOffset = typeof element.start === "number" ? element.start - 1 : 0
500
+
501
+ const computeOffset = () => {
502
+ if (!element.ordered) return undefined
503
+ if (startOffset > 0 && lastItemNumber === 0) return startOffset
504
+ if (lastItemNumber > 0) return lastItemNumber
505
+ return undefined
506
+ }
507
+
508
+ const flushSimpleItems = () => {
509
+ if (currentSimpleItems.length === 0) return
510
+ blocks.push(richTextList(currentSimpleItems, listStyle, indent, computeOffset()))
511
+ lastItemNumber += currentSimpleItems.length
512
+ currentSimpleItems = []
513
+ }
514
+
322
515
  for (const item of element.items) {
323
- const itemElements: RichTextSectionElement[] = []
516
+ const isTaskItem = "checked" in item && typeof item.checked === "boolean"
517
+ const checkboxText = isTaskItem && item.checked !== undefined ? checkboxPrefix(item.checked) : ""
518
+
519
+ // Filter out checkbox tokens
520
+ const contentTokens = item.tokens.filter((t) => (t as { type: string }).type !== "checkbox")
521
+
522
+ if (isSimpleListItem(contentTokens)) {
523
+ let elements = getInlineElements(contentTokens)
524
+ if (checkboxText && elements.length > 0) {
525
+ if (elements[0].type === "text") {
526
+ elements = [{ ...elements[0], text: checkboxText + elements[0].text }, ...elements.slice(1)]
527
+ } else {
528
+ elements = [{ type: "text", text: checkboxText }, ...elements]
529
+ }
530
+ }
531
+ if (elements.length > 0) {
532
+ currentSimpleItems.push({ type: "rich_text_section", elements })
533
+ }
534
+ continue
535
+ }
536
+
537
+ // Complex item - flush accumulated simple items first
538
+ flushSimpleItems()
539
+
540
+ if (contentTokens.length === 0) continue
541
+
542
+ // Separate inline tokens (first group) from block tokens
543
+ const inlineTokens: marked.Token[] = []
544
+ let blockStartIndex = contentTokens.length
545
+ for (let i = 0; i < contentTokens.length; i++) {
546
+ if (BLOCK_TOKEN_TYPES.has(contentTokens[i].type)) {
547
+ blockStartIndex = i
548
+ break
549
+ }
550
+ inlineTokens.push(contentTokens[i])
551
+ }
324
552
 
325
- if (item.task) {
326
- const prefix = checkboxPrefix(item.checked || false)
327
- itemElements.push({ type: "text", text: prefix })
553
+ // First inline tokens become the list item text
554
+ let firstItemElements = getInlineElements(inlineTokens)
555
+ if (checkboxText && firstItemElements.length > 0) {
556
+ if (firstItemElements[0].type === "text") {
557
+ firstItemElements = [{ ...firstItemElements[0], text: checkboxText + firstItemElements[0].text }, ...firstItemElements.slice(1)]
558
+ } else {
559
+ firstItemElements = [{ type: "text", text: checkboxText }, ...firstItemElements]
560
+ }
328
561
  }
329
562
 
330
- for (const token of item.tokens) {
331
- if (token.type === "text" || token.type === "paragraph") {
332
- const textToken = token as marked.Tokens.Text | marked.Tokens.Paragraph
333
- if (!textToken.tokens?.length) {
334
- continue
563
+ if (firstItemElements.length > 0) {
564
+ blocks.push(richTextList([{ type: "rich_text_section", elements: firstItemElements }], listStyle, indent, computeOffset()))
565
+ lastItemNumber++
566
+ }
567
+
568
+ // Process remaining block-level tokens
569
+ type RichTextBlockElement = RichTextBlock["elements"][number]
570
+ const remainingElements: RichTextBlockElement[] = []
571
+
572
+ for (let i = blockStartIndex; i < contentTokens.length; i++) {
573
+ const token = contentTokens[i]
574
+ if (token.type === "paragraph" || token.type === "text" || token.type === "html") {
575
+ const elements = getInlineElements([token])
576
+ if (elements.length > 0) {
577
+ remainingElements.push({ type: "rich_text_section", elements })
335
578
  }
336
- const richElements = tokensToRichTextElements(textToken.tokens as PhrasingToken[])
337
- itemElements.push(...richElements)
338
579
  } else if (token.type === "code") {
339
- const codeToken = token as marked.Tokens.Code
340
- itemElements.push({
341
- type: "text",
342
- text: `\n${codeToken.text}\n`,
343
- style: { code: true }
580
+ const code = token as marked.Tokens.Code
581
+ remainingElements.push({
582
+ type: "rich_text_preformatted",
583
+ elements: [{ type: "text", text: code.text }]
344
584
  })
345
- } else if (token.type === "html") {
346
- const htmlToken = token as marked.Tokens.HTML
347
- const slackElements = parseRawSlackSpecial(htmlToken.raw)
348
- if (slackElements) {
349
- itemElements.push(...slackElements)
585
+ } else if (token.type === "blockquote") {
586
+ const bqToken = token as marked.Tokens.Blockquote
587
+ const bqBlocks = parseBlockquote(bqToken)
588
+ for (const block of bqBlocks) {
589
+ if (block.type === "rich_text") {
590
+ remainingElements.push(...(block as RichTextBlock).elements)
591
+ } else {
592
+ if (remainingElements.length > 0) {
593
+ blocks.push({ type: "rich_text", elements: [...remainingElements] } as RichTextBlock)
594
+ remainingElements.length = 0
595
+ }
596
+ blocks.push(block as KnownBlock)
597
+ }
598
+ }
599
+ } else if (token.type === "list") {
600
+ // Nested list with increased indent
601
+ const nestedBlocks = parseList(token as marked.Tokens.List, options, indent + 1)
602
+ for (const block of nestedBlocks) {
603
+ if (block.type === "rich_text") {
604
+ remainingElements.push(...(block as RichTextBlock).elements)
605
+ } else {
606
+ if (remainingElements.length > 0) {
607
+ blocks.push({ type: "rich_text", elements: [...remainingElements] } as RichTextBlock)
608
+ remainingElements.length = 0
609
+ }
610
+ blocks.push(block)
611
+ }
350
612
  }
351
613
  }
352
614
  }
353
615
 
354
- items.push({ type: "rich_text_section", elements: itemElements })
616
+ if (remainingElements.length > 0) {
617
+ blocks.push({ type: "rich_text", elements: remainingElements } as RichTextBlock)
618
+ }
355
619
  }
356
620
 
357
- let style: "bullet" | "ordered" = "bullet"
358
- if (element.ordered) {
359
- style = "ordered"
360
- }
621
+ // Flush remaining simple items
622
+ flushSimpleItems()
361
623
 
362
- return richTextList(items, style, indent)
624
+ return blocks
363
625
  }
364
626
 
365
627
  function parseTableCellToBlock(cell: marked.Tokens.TableCell): TableCell {
@@ -420,6 +682,13 @@ function parseTable(element: marked.Tokens.Table): TableBlock {
420
682
  return table(tableRows, columnSettings.length > 0 ? columnSettings : undefined)
421
683
  }
422
684
 
685
+ function prefixWithBlockquoteMarker(text: string): string {
686
+ return text
687
+ .split("\n")
688
+ .map((line) => (line.trim() ? `> ${line}` : ">"))
689
+ .join("\n")
690
+ }
691
+
423
692
  function parseBlockquote(element: marked.Tokens.Blockquote): (KnownBlock | TableBlock | RichTextBlock | VideoBlock)[] {
424
693
  const onlyParagraphs = element.tokens.every((token) => token.type === "paragraph" || token.type === "text" || token.type === "space")
425
694
 
@@ -455,10 +724,11 @@ function parseBlockquote(element: marked.Tokens.Blockquote): (KnownBlock | Table
455
724
 
456
725
  // Complex blockquotes: fall back to section blocks with > prefix
457
726
  const blocks = element.tokens.flatMap((token) => {
727
+ if (token.type === "space") return []
458
728
  if (token.type === "paragraph") {
459
729
  return parseParagraph(token)
460
730
  } else if (token.type === "list") {
461
- return [parseList(token)]
731
+ return parseList(token)
462
732
  } else if (token.type === "code") {
463
733
  return [parseCode(token)]
464
734
  } else if (token.type === "blockquote") {
@@ -473,7 +743,21 @@ function parseBlockquote(element: marked.Tokens.Blockquote): (KnownBlock | Table
473
743
 
474
744
  return blocks.map((block) => {
475
745
  if ("type" in block && block.type === "section" && (block as SectionBlock).text?.text) {
476
- ;(block as SectionBlock).text!.text = "> " + (block as SectionBlock).text!.text.replace(/\n/g, "\n> ")
746
+ ;(block as SectionBlock).text!.text = prefixWithBlockquoteMarker((block as SectionBlock).text!.text)
747
+ } else if ("type" in block && block.type === "rich_text") {
748
+ // Convert rich_text blocks to section blocks with > prefix
749
+ const richBlock = block as RichTextBlock
750
+ const plainText = richBlock.elements
751
+ .map((el) => {
752
+ if (el.type === "rich_text_section") {
753
+ return el.elements.map((e) => (e.type === "text" ? e.text : "")).join("")
754
+ }
755
+ return ""
756
+ })
757
+ .join("\n")
758
+ if (plainText.trim()) {
759
+ return section(prefixWithBlockquoteMarker(plainText))
760
+ }
477
761
  }
478
762
  return block
479
763
  })
@@ -483,21 +767,45 @@ function parseThematicBreak(): DividerBlock {
483
767
  return divider()
484
768
  }
485
769
 
486
- function parseHTML(element: marked.Tokens.HTML | marked.Tokens.Tag): (KnownBlock | TableBlock | VideoBlock)[] {
770
+ /**
771
+ * Normalizes HTML for XML parsing by closing self-closing tags.
772
+ * Converts `<img src="...">` to `<img src="..." />` for the XML parser.
773
+ */
774
+ function normalizeHtmlForParsing(html: string): string {
775
+ const selfClosingTags = ["img", "br", "hr", "input", "meta", "link", "area", "base", "col", "embed", "source", "track", "wbr"]
776
+ return html.replace(/<(\w+)([^>]*?)(?:\s*\/)?>/gi, (match, tagName, attributes) => {
777
+ if (selfClosingTags.includes(tagName.toLowerCase()) && !match.endsWith("/>")) {
778
+ return `<${tagName}${attributes} />`
779
+ }
780
+ return match
781
+ })
782
+ }
783
+
784
+ function parseHTML(element: marked.Tokens.HTML | marked.Tokens.Tag): (KnownBlock | RichTextBlock | TableBlock | VideoBlock)[] {
785
+ const htmlText = element.raw.trim()
786
+ if (!htmlText) return []
787
+
788
+ // Check if this is a Slack special formatting pattern that marked parsed as HTML
789
+ const slackElements = parseRawSlackSpecial(htmlText)
790
+ if (slackElements) {
791
+ return [{ type: "rich_text", elements: [{ type: "rich_text_section", elements: slackElements }] } as RichTextBlock]
792
+ }
793
+
487
794
  try {
795
+ const normalizedHtml = normalizeHtmlForParsing(htmlText)
488
796
  const parser = new XMLParser(SECURE_XML_CONFIG)
489
- const res = parser.parse(element.raw)
797
+ const res = parser.parse(normalizedHtml)
490
798
  const blocks: (KnownBlock | TableBlock | VideoBlock)[] = []
491
799
 
492
800
  if (res.img) {
493
801
  const tags = res.img instanceof Array ? res.img : [res.img]
494
802
  const imageBlocks = tags
495
803
  .map((img: Record<string, string>) => {
496
- const url: string = img["@_src"]
804
+ const url: string = img["@_src"] || img["@_href"]
497
805
  if (!validateUrl(url)) {
498
806
  return null
499
807
  }
500
- return image(url, img["@_alt"] || url)
808
+ return image(url, img["@_alt"] || img["@_title"] || url)
501
809
  })
502
810
  .filter((e: ImageBlock | null) => e !== null) as ImageBlock[]
503
811
  blocks.push(...imageBlocks)
@@ -507,10 +815,10 @@ function parseHTML(element: marked.Tokens.HTML | marked.Tokens.Tag): (KnownBlock
507
815
  const tags = res.video instanceof Array ? res.video : [res.video]
508
816
  const videoBlocks = tags
509
817
  .map((vid: Record<string, unknown>) => {
510
- const videoUrl = String(vid["@_src"] || "")
511
- const posterUrl = String(vid["@_poster"] || "")
512
- const title = String(vid["@_title"] || "Video")
513
- const altText = String(vid["@_alt"] || title)
818
+ const videoUrl = String(vid["@_src"] || vid["@_href"] || "")
819
+ const posterUrl = String(vid["@_poster"] || vid["@_thumbnail"] || "")
820
+ const title = String(vid["@_title"] || vid["@_alt"] || vid["@_aria-label"] || "Video")
821
+ const altText = String(vid["@_alt"] || vid["@_title"] || vid["@_aria-label"] || title)
514
822
 
515
823
  if (!videoUrl || !validateUrl(videoUrl)) {
516
824
  return null
@@ -521,7 +829,12 @@ function parseHTML(element: marked.Tokens.HTML | marked.Tokens.Tag): (KnownBlock
521
829
  videoUrl,
522
830
  thumbnailUrl: posterUrl || videoUrl,
523
831
  title,
524
- altText
832
+ altText,
833
+ ...(vid["@_data-title-url"] && { titleUrl: String(vid["@_data-title-url"]) }),
834
+ ...(vid["@_data-provider-name"] && { providerName: String(vid["@_data-provider-name"]) }),
835
+ ...(vid["@_data-provider-icon-url"] && { providerIconUrl: String(vid["@_data-provider-icon-url"]) }),
836
+ ...(vid["@_data-description"] && { description: String(vid["@_data-description"]) }),
837
+ ...(vid["@_data-author-name"] && { authorName: String(vid["@_data-author-name"]) })
525
838
  })
526
839
  } catch {
527
840
  return null
@@ -552,7 +865,7 @@ function parseToken(token: marked.Token, options: ParsingOptions): (KnownBlock |
552
865
  return parseBlockquote(token)
553
866
 
554
867
  case "list":
555
- return [parseList(token, options.lists)]
868
+ return parseList(token, options.lists)
556
869
 
557
870
  case "table":
558
871
  return [parseTable(token)]