@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.
- package/.github/workflows/release.yml +43 -0
- package/LICENSE +21 -0
- package/README.md +69 -0
- package/build/src/errors.d.ts +22 -0
- package/build/src/errors.js +80 -0
- package/build/src/index.d.ts +23 -0
- package/build/src/index.js +109 -0
- package/build/src/parser/internal.d.ts +5 -0
- package/build/src/parser/internal.js +506 -0
- package/build/src/slack.d.ts +134 -0
- package/build/src/slack.js +135 -0
- package/build/src/types.d.ts +6 -0
- package/build/src/types.js +2 -0
- package/build/src/validation.d.ts +41 -0
- package/build/src/validation.js +105 -0
- package/bun.lock +1261 -0
- package/package.json +41 -2
- package/src/errors.ts +52 -0
- package/src/index.ts +93 -0
- package/src/parser/internal.ts +573 -0
- package/src/slack.ts +288 -0
- package/src/types.ts +9 -0
- package/src/validation.ts +115 -0
- package/tests/fixtures/blockquote.md +9 -0
- package/tests/fixtures/headings.md +11 -0
- package/tests/fixtures/lists.md +32 -0
- package/tests/fixtures/sample2.md +69 -0
- package/tests/fixtures/sample3.md +111 -0
- package/tests/fixtures/tables.md +41 -0
- package/tests/integration.spec.ts +578 -0
- package/tests/parser.spec.ts +175 -0
- package/tests/tsconfig.json +6 -0
- package/tsconfig.json +12 -0
- package/vitest.config.ts +9 -0
|
@@ -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 < > &, so we match escaped forms.
|
|
148
|
+
*/
|
|
149
|
+
function parseSlackSpecialFormatting(text: string): RichTextSectionElement[] {
|
|
150
|
+
// Match escaped angle brackets: <...>
|
|
151
|
+
const slackPattern = /<(@[A-Z0-9]+(?:\|[^&]+)?|#[A-Z0-9]+(?:\|[^&]+)?|![a-z]+(?:\^[^&]+)*(?:\|[^&]+)?|https?:\/\/[^|&]+\|[^&]+|https?:\/\/[^&]+)>/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
|
+
}
|