@circlesac/mack 0.0.0 → 26.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,573 @@
1
+ import { DividerBlock, HeaderBlock, ImageBlock, KnownBlock, SectionBlock } from "@slack/types"
2
+ import { XMLParser } from "fast-xml-parser"
3
+ import { marked } from "marked"
4
+ import {
5
+ ColumnSetting,
6
+ divider,
7
+ header,
8
+ image,
9
+ RichTextBlock,
10
+ RichTextElement,
11
+ RichTextSectionElement,
12
+ richTextCode,
13
+ richTextList,
14
+ richTextQuote,
15
+ section,
16
+ TableBlock,
17
+ TableCell,
18
+ TableRow,
19
+ table,
20
+ VideoBlock,
21
+ video
22
+ } from "../slack"
23
+ import { ListOptions, ParsingOptions } from "../types"
24
+ import { SECURE_XML_CONFIG, validateRecursionDepth, validateUrl } from "../validation"
25
+
26
+ type PhrasingToken =
27
+ | marked.Tokens.Link
28
+ | marked.Tokens.Em
29
+ | marked.Tokens.Strong
30
+ | marked.Tokens.Del
31
+ | marked.Tokens.Br
32
+ | marked.Tokens.Image
33
+ | marked.Tokens.Codespan
34
+ | marked.Tokens.Text
35
+ | marked.Tokens.HTML
36
+
37
+ let recursionDepth = 0
38
+
39
+ function parsePlainText(element: PhrasingToken): string[] {
40
+ switch (element.type) {
41
+ case "link":
42
+ case "em":
43
+ case "strong":
44
+ case "del":
45
+ return element.tokens.flatMap((child) => parsePlainText(child as PhrasingToken))
46
+
47
+ case "br":
48
+ return []
49
+
50
+ case "image":
51
+ return [element.title ?? element.href]
52
+
53
+ case "codespan":
54
+ case "text":
55
+ case "html":
56
+ return [element.raw]
57
+ }
58
+ }
59
+
60
+ function isSectionBlock(block: KnownBlock): block is SectionBlock {
61
+ return block.type === "section"
62
+ }
63
+
64
+ function parseMrkdwn(element: Exclude<PhrasingToken, marked.Tokens.Image>): string {
65
+ recursionDepth++
66
+ try {
67
+ validateRecursionDepth(recursionDepth)
68
+
69
+ switch (element.type) {
70
+ case "link": {
71
+ const href = element.href && validateUrl(element.href) ? element.href : ""
72
+ if (!href) {
73
+ return element.tokens.flatMap((child) => parseMrkdwn(child as Exclude<PhrasingToken, marked.Tokens.Image>)).join("")
74
+ }
75
+ return `<${href}|${element.tokens.flatMap((child) => parseMrkdwn(child as Exclude<PhrasingToken, marked.Tokens.Image>)).join("")}> `
76
+ }
77
+
78
+ case "em": {
79
+ return `_${element.tokens.flatMap((child) => parseMrkdwn(child as Exclude<PhrasingToken, marked.Tokens.Image>)).join("")}_`
80
+ }
81
+
82
+ case "codespan":
83
+ return `\`${element.text}\``
84
+
85
+ case "strong": {
86
+ return `*${element.tokens.flatMap((child) => parseMrkdwn(child as Exclude<PhrasingToken, marked.Tokens.Image>)).join("")}*`
87
+ }
88
+
89
+ case "text":
90
+ return element.text
91
+
92
+ case "del": {
93
+ return `~${element.tokens.flatMap((child) => parseMrkdwn(child as Exclude<PhrasingToken, marked.Tokens.Image>)).join("")}~`
94
+ }
95
+
96
+ default:
97
+ return ""
98
+ }
99
+ } finally {
100
+ recursionDepth--
101
+ }
102
+ }
103
+
104
+ function addMrkdwn(content: string, accumulator: (SectionBlock | ImageBlock)[]) {
105
+ const last = accumulator[accumulator.length - 1]
106
+
107
+ if (last && isSectionBlock(last) && last.text) {
108
+ last.text.text += content
109
+ } else {
110
+ accumulator.push(section(content))
111
+ }
112
+ }
113
+
114
+ function parsePhrasingContent(element: PhrasingToken, accumulator: (SectionBlock | ImageBlock)[]) {
115
+ if (element.type === "image") {
116
+ const imageBlock: ImageBlock = image(element.href, element.text || element.title || element.href, element.title)
117
+ accumulator.push(imageBlock)
118
+ } else {
119
+ const text = parseMrkdwn(element)
120
+ addMrkdwn(text, accumulator)
121
+ }
122
+ }
123
+
124
+ function parseParagraph(element: marked.Tokens.Paragraph): KnownBlock[] {
125
+ return element.tokens.reduce(
126
+ (accumulator, child) => {
127
+ parsePhrasingContent(child as PhrasingToken, accumulator)
128
+ return accumulator
129
+ },
130
+ [] as (SectionBlock | ImageBlock)[]
131
+ )
132
+ }
133
+
134
+ function parseHeading(element: marked.Tokens.Heading): HeaderBlock {
135
+ return header(element.tokens.flatMap((child) => parsePlainText(child as PhrasingToken)).join(""))
136
+ }
137
+
138
+ function parseCode(element: marked.Tokens.Code): RichTextBlock {
139
+ return richTextCode(element.text)
140
+ }
141
+
142
+ /**
143
+ * Parses Slack special formatting patterns in text and returns rich text elements.
144
+ * Handles: <@USER_ID>, <#CHANNEL_ID>, <!here>, <!channel>, <!everyone>,
145
+ * <!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
+ */
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
152
+
153
+ const elements: RichTextSectionElement[] = []
154
+ let lastIndex = 0
155
+ let match: RegExpExecArray | null
156
+
157
+ while ((match = slackPattern.exec(text)) !== null) {
158
+ if (match.index > lastIndex) {
159
+ elements.push({ type: "text", text: text.slice(lastIndex, match.index) })
160
+ }
161
+
162
+ const content = match[1]
163
+
164
+ if (content.startsWith("@")) {
165
+ const [userId] = content.slice(1).split("|")
166
+ elements.push({ type: "user", user_id: userId })
167
+ } else if (content.startsWith("#")) {
168
+ const [channelId] = content.slice(1).split("|")
169
+ elements.push({ type: "channel", channel_id: channelId })
170
+ } else if (content.startsWith("!")) {
171
+ if (content === "!here") {
172
+ elements.push({ type: "broadcast", range: "here" })
173
+ } else if (content === "!channel") {
174
+ elements.push({ type: "broadcast", range: "channel" })
175
+ } else if (content === "!everyone") {
176
+ elements.push({ type: "broadcast", range: "everyone" })
177
+ } else if (content.startsWith("!subteam^")) {
178
+ const usergroupId = content.slice(9)
179
+ elements.push({ type: "usergroup", usergroup_id: usergroupId })
180
+ } else if (content.startsWith("!date^")) {
181
+ const dateContent = content.slice(6)
182
+ const parts = dateContent.split("|")
183
+ const formatParts = (parts[0] || "").split("^")
184
+ const timestamp = parseInt(formatParts[0] || "0", 10)
185
+ const format = formatParts.slice(1).join("^") || "{date_pretty}"
186
+ const fallback = parts[1]
187
+ elements.push({
188
+ type: "date",
189
+ timestamp,
190
+ format,
191
+ ...(fallback && { fallback })
192
+ })
193
+ } else {
194
+ elements.push({ type: "text", text: match[0] })
195
+ }
196
+ } else if (content.startsWith("http://") || content.startsWith("https://")) {
197
+ const pipeIndex = content.indexOf("|")
198
+ if (pipeIndex !== -1) {
199
+ elements.push({
200
+ type: "link",
201
+ url: content.slice(0, pipeIndex),
202
+ text: content.slice(pipeIndex + 1)
203
+ })
204
+ } else {
205
+ elements.push({ type: "link", url: content })
206
+ }
207
+ } else {
208
+ elements.push({ type: "text", text: match[0] })
209
+ }
210
+
211
+ lastIndex = match.index + match[0].length
212
+ }
213
+
214
+ if (lastIndex < text.length) {
215
+ elements.push({ type: "text", text: text.slice(lastIndex) })
216
+ }
217
+
218
+ if (elements.length === 0) {
219
+ return [{ type: "text", text }]
220
+ }
221
+
222
+ return elements
223
+ }
224
+
225
+ /**
226
+ * Converts inline marked tokens to RichTextSectionElements for use in
227
+ * rich_text_list items, table cells, and blockquotes.
228
+ */
229
+ function tokensToRichTextElements(tokens: PhrasingToken[]): RichTextSectionElement[] {
230
+ const elements: RichTextSectionElement[] = []
231
+
232
+ for (const token of tokens) {
233
+ switch (token.type) {
234
+ case "text":
235
+ elements.push(...parseSlackSpecialFormatting(token.text))
236
+ break
237
+
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
+ }
243
+
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
+ }
249
+
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
254
+ }
255
+
256
+ case "codespan":
257
+ elements.push({ type: "text", text: token.text, style: { code: true } })
258
+ break
259
+
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 })
266
+ }
267
+ break
268
+ }
269
+
270
+ case "image":
271
+ elements.push({
272
+ type: "text",
273
+ text: token.text || token.title || "[image]"
274
+ })
275
+ break
276
+
277
+ default:
278
+ if ("text" in token && typeof (token as { text?: string }).text === "string") {
279
+ elements.push({ type: "text", text: (token as { text: string }).text })
280
+ }
281
+ }
282
+ }
283
+
284
+ return elements
285
+ }
286
+
287
+ /**
288
+ * Parses raw (unescaped) HTML tokens that may be Slack special patterns.
289
+ * marked parses patterns like <!here>, <!channel>, <!everyone> as HTML
290
+ * declarations, so they appear as html tokens rather than text tokens.
291
+ */
292
+ function parseRawSlackSpecial(raw: string): RichTextSectionElement[] | null {
293
+ const trimmed = raw.trim()
294
+ const match = trimmed.match(/^<(!(?:here|channel|everyone)|!subteam\^[A-Z0-9]+|@[A-Z0-9]+|#[A-Z0-9]+)>$/)
295
+ if (!match) return null
296
+
297
+ 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" }]
301
+ if (content.startsWith("!subteam^")) {
302
+ return [{ type: "usergroup", usergroup_id: content.slice(9) }]
303
+ }
304
+ if (content.startsWith("@")) {
305
+ return [{ type: "user", user_id: content.slice(1) }]
306
+ }
307
+ if (content.startsWith("#")) {
308
+ return [{ type: "channel", channel_id: content.slice(1) }]
309
+ }
310
+ return null
311
+ }
312
+
313
+ function parseList(element: marked.Tokens.List, options: ListOptions = {}, indent = 0): RichTextBlock {
314
+ const items: RichTextElement[] = []
315
+
316
+ const defaultCheckboxPrefix = (checked: boolean): string => {
317
+ return checked ? "\u2611 " : "\u2610 "
318
+ }
319
+
320
+ const checkboxPrefix = options.checkboxPrefix || defaultCheckboxPrefix
321
+
322
+ for (const item of element.items) {
323
+ const itemElements: RichTextSectionElement[] = []
324
+
325
+ if (item.task) {
326
+ const prefix = checkboxPrefix(item.checked || false)
327
+ itemElements.push({ type: "text", text: prefix })
328
+ }
329
+
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
335
+ }
336
+ const richElements = tokensToRichTextElements(textToken.tokens as PhrasingToken[])
337
+ itemElements.push(...richElements)
338
+ } 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 }
344
+ })
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)
350
+ }
351
+ }
352
+ }
353
+
354
+ items.push({ type: "rich_text_section", elements: itemElements })
355
+ }
356
+
357
+ let style: "bullet" | "ordered" = "bullet"
358
+ if (element.ordered) {
359
+ style = "ordered"
360
+ }
361
+
362
+ return richTextList(items, style, indent)
363
+ }
364
+
365
+ function parseTableCellToBlock(cell: marked.Tokens.TableCell): TableCell {
366
+ const hasComplexFormatting = cell.tokens.some((token) => {
367
+ const tokenType = (token as PhrasingToken).type
368
+ return ["strong", "em", "del", "link", "codespan"].includes(tokenType)
369
+ })
370
+
371
+ if (hasComplexFormatting) {
372
+ const elements = tokensToRichTextElements(cell.tokens as PhrasingToken[])
373
+ return {
374
+ type: "rich_text",
375
+ elements: [{ type: "rich_text_section", elements }]
376
+ }
377
+ } else {
378
+ const text = cell.tokens
379
+ .map((token) => {
380
+ if ("text" in token) {
381
+ return (token as marked.Tokens.Text).text
382
+ }
383
+ return ""
384
+ })
385
+ .join("")
386
+ return { type: "raw_text", text }
387
+ }
388
+ }
389
+
390
+ function parseTableRowsToBlocks(
391
+ headerCells: marked.Tokens.TableCell[],
392
+ rows: marked.Tokens.TableCell[][],
393
+ align: Array<"left" | "center" | "right" | null>
394
+ ): { tableRows: TableRow[]; columnSettings: ColumnSetting[] } {
395
+ const tableRows: TableRow[] = []
396
+
397
+ const headerRow: TableCell[] = headerCells.map((cell) => parseTableCellToBlock(cell))
398
+ tableRows.push(headerRow)
399
+
400
+ for (const row of rows) {
401
+ const tableRow: TableCell[] = row.map((cell) => parseTableCellToBlock(cell))
402
+ tableRows.push(tableRow)
403
+ }
404
+
405
+ const columnSettings: ColumnSetting[] = []
406
+ for (let i = 0; i < align.length && i < 20; i++) {
407
+ if (align[i] && align[i] !== "left") {
408
+ columnSettings.push({ align: align[i] as "center" | "right" })
409
+ } else if (columnSettings.length > 0) {
410
+ columnSettings.push({})
411
+ }
412
+ }
413
+
414
+ return { tableRows, columnSettings }
415
+ }
416
+
417
+ function parseTable(element: marked.Tokens.Table): TableBlock {
418
+ const { tableRows, columnSettings } = parseTableRowsToBlocks(element.header, element.rows, element.align)
419
+
420
+ return table(tableRows, columnSettings.length > 0 ? columnSettings : undefined)
421
+ }
422
+
423
+ function parseBlockquote(element: marked.Tokens.Blockquote): (KnownBlock | TableBlock | RichTextBlock | VideoBlock)[] {
424
+ const onlyParagraphs = element.tokens.every((token) => token.type === "paragraph" || token.type === "text" || token.type === "space")
425
+
426
+ if (onlyParagraphs) {
427
+ const quoteElements: RichTextSectionElement[] = []
428
+
429
+ for (const token of element.tokens) {
430
+ if (token.type === "paragraph") {
431
+ const paragraphToken = token as marked.Tokens.Paragraph
432
+ if (paragraphToken.tokens?.length) {
433
+ const richElements = tokensToRichTextElements(paragraphToken.tokens as PhrasingToken[])
434
+ quoteElements.push(...richElements)
435
+ if (element.tokens.indexOf(token) < element.tokens.length - 1) {
436
+ quoteElements.push({ type: "text", text: "\n" })
437
+ }
438
+ }
439
+ } else if (token.type === "text") {
440
+ const textToken = token as marked.Tokens.Text
441
+ if (textToken.tokens?.length) {
442
+ const richElements = tokensToRichTextElements(textToken.tokens as PhrasingToken[])
443
+ quoteElements.push(...richElements)
444
+ } else {
445
+ quoteElements.push({ type: "text", text: textToken.text })
446
+ }
447
+ }
448
+ }
449
+
450
+ if (quoteElements.length > 0) {
451
+ return [richTextQuote(quoteElements)]
452
+ }
453
+ return []
454
+ }
455
+
456
+ // Complex blockquotes: fall back to section blocks with > prefix
457
+ const blocks = element.tokens.flatMap((token) => {
458
+ if (token.type === "paragraph") {
459
+ return parseParagraph(token)
460
+ } else if (token.type === "list") {
461
+ return [parseList(token)]
462
+ } else if (token.type === "code") {
463
+ return [parseCode(token)]
464
+ } else if (token.type === "blockquote") {
465
+ return parseBlockquote(token)
466
+ } else if (token.type === "heading") {
467
+ return [parseHeading(token)]
468
+ } else if (token.type === "html") {
469
+ return parseHTML(token)
470
+ }
471
+ return []
472
+ })
473
+
474
+ return blocks.map((block) => {
475
+ 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> ")
477
+ }
478
+ return block
479
+ })
480
+ }
481
+
482
+ function parseThematicBreak(): DividerBlock {
483
+ return divider()
484
+ }
485
+
486
+ function parseHTML(element: marked.Tokens.HTML | marked.Tokens.Tag): (KnownBlock | TableBlock | VideoBlock)[] {
487
+ try {
488
+ const parser = new XMLParser(SECURE_XML_CONFIG)
489
+ const res = parser.parse(element.raw)
490
+ const blocks: (KnownBlock | TableBlock | VideoBlock)[] = []
491
+
492
+ if (res.img) {
493
+ const tags = res.img instanceof Array ? res.img : [res.img]
494
+ const imageBlocks = tags
495
+ .map((img: Record<string, string>) => {
496
+ const url: string = img["@_src"]
497
+ if (!validateUrl(url)) {
498
+ return null
499
+ }
500
+ return image(url, img["@_alt"] || url)
501
+ })
502
+ .filter((e: ImageBlock | null) => e !== null) as ImageBlock[]
503
+ blocks.push(...imageBlocks)
504
+ }
505
+
506
+ if (res.video) {
507
+ const tags = res.video instanceof Array ? res.video : [res.video]
508
+ const videoBlocks = tags
509
+ .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)
514
+
515
+ if (!videoUrl || !validateUrl(videoUrl)) {
516
+ return null
517
+ }
518
+
519
+ try {
520
+ return video({
521
+ videoUrl,
522
+ thumbnailUrl: posterUrl || videoUrl,
523
+ title,
524
+ altText
525
+ })
526
+ } catch {
527
+ return null
528
+ }
529
+ })
530
+ .filter((e: VideoBlock | null) => e !== null) as VideoBlock[]
531
+ blocks.push(...videoBlocks)
532
+ }
533
+
534
+ return blocks
535
+ } catch {
536
+ return []
537
+ }
538
+ }
539
+
540
+ function parseToken(token: marked.Token, options: ParsingOptions): (KnownBlock | TableBlock | RichTextBlock | VideoBlock)[] {
541
+ switch (token.type) {
542
+ case "heading":
543
+ return [parseHeading(token)]
544
+
545
+ case "paragraph":
546
+ return parseParagraph(token)
547
+
548
+ case "code":
549
+ return [parseCode(token)]
550
+
551
+ case "blockquote":
552
+ return parseBlockquote(token)
553
+
554
+ case "list":
555
+ return [parseList(token, options.lists)]
556
+
557
+ case "table":
558
+ return [parseTable(token)]
559
+
560
+ case "hr":
561
+ return [parseThematicBreak()]
562
+
563
+ case "html":
564
+ return parseHTML(token)
565
+
566
+ default:
567
+ return []
568
+ }
569
+ }
570
+
571
+ export function parseBlocks(tokens: marked.TokensList, options: ParsingOptions = {}): (KnownBlock | TableBlock | RichTextBlock | VideoBlock)[] {
572
+ return tokens.flatMap((token) => parseToken(token, options))
573
+ }