@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.
- package/build/src/index.js +3 -8
- package/build/src/parser/internal.js +402 -121
- package/build/src/slack.d.ts +1 -1
- package/build/src/slack.js +13 -7
- package/bun.lock +10 -795
- package/package.json +1 -3
- package/src/index.ts +7 -16
- package/src/parser/internal.ts +425 -112
- package/src/slack.ts +2 -1
- package/tests/integration.spec.ts +208 -8
package/src/parser/internal.ts
CHANGED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
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 < > &, so we match escaped forms.
|
|
148
162
|
*/
|
|
149
|
-
function parseSlackSpecialFormatting(text: string): RichTextSectionElement[] {
|
|
150
|
-
|
|
151
|
-
const slackPattern = /<(@[A-Z0-9]+(?:\|[^&]+)?|#[A-Z0-9]+(?:\|[^&]+)?|![a-z]+(?:\^[^&]+)*(?:\|[^&]+)?|https?:\/\/[^|&]+\|[^&]+|https?:\/\/[^&]+)>/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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
227
|
-
*
|
|
243
|
+
* Creates a text element with Slack special formatting parsing.
|
|
244
|
+
* Applies soft line break conversion (single \n → space).
|
|
228
245
|
*/
|
|
229
|
-
function
|
|
230
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
elements.push({ type: "text", text: strongText, style: { bold: true } })
|
|
241
|
-
break
|
|
242
|
-
}
|
|
255
|
+
return parseSlackSpecialFormatting(text, style)
|
|
256
|
+
}
|
|
243
257
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
314
|
-
|
|
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
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
|
340
|
-
|
|
341
|
-
type: "
|
|
342
|
-
text:
|
|
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 === "
|
|
346
|
-
const
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
616
|
+
if (remainingElements.length > 0) {
|
|
617
|
+
blocks.push({ type: "rich_text", elements: remainingElements } as RichTextBlock)
|
|
618
|
+
}
|
|
355
619
|
}
|
|
356
620
|
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
style = "ordered"
|
|
360
|
-
}
|
|
621
|
+
// Flush remaining simple items
|
|
622
|
+
flushSimpleItems()
|
|
361
623
|
|
|
362
|
-
return
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
|
868
|
+
return parseList(token, options.lists)
|
|
556
869
|
|
|
557
870
|
case "table":
|
|
558
871
|
return [parseTable(token)]
|