safe-mdx 1.3.7 → 1.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/html/html-and-md.test.d.ts +2 -0
- package/dist/html/html-and-md.test.d.ts.map +1 -0
- package/dist/html/html-and-md.test.js +869 -0
- package/dist/html/html-and-md.test.js.map +1 -0
- package/dist/html/html-to-mdx-ast.d.ts.map +1 -1
- package/dist/html/html-to-mdx-ast.js.map +1 -1
- package/dist/html/html-to-mdx-ast.test.js +43 -0
- package/dist/html/html-to-mdx-ast.test.js.map +1 -1
- package/dist/safe-mdx.d.ts +1 -1
- package/dist/safe-mdx.d.ts.map +1 -1
- package/dist/safe-mdx.js +2 -2
- package/dist/safe-mdx.js.map +1 -1
- package/package.json +4 -4
- package/src/html/html-and-md.test.ts +953 -0
- package/src/html/html-to-mdx-ast.test.ts +48 -0
- package/src/html/html-to-mdx-ast.ts +9 -8
- package/src/safe-mdx.tsx +3 -3
|
@@ -0,0 +1,953 @@
|
|
|
1
|
+
import { test, expect, describe } from 'vitest'
|
|
2
|
+
import { unified, Plugin } from 'unified'
|
|
3
|
+
|
|
4
|
+
import remarkMdx from 'remark-mdx'
|
|
5
|
+
import remarkStringify from 'remark-stringify'
|
|
6
|
+
import remarkParse from 'remark-parse'
|
|
7
|
+
|
|
8
|
+
import { parseHtmlToMdxAst } from 'safe-mdx/parse'
|
|
9
|
+
import { Root, RootContent } from 'mdast'
|
|
10
|
+
import { remark } from 'remark'
|
|
11
|
+
import { visit } from 'unist-util-visit'
|
|
12
|
+
|
|
13
|
+
/** Template literal for auto formatting with dedent */
|
|
14
|
+
function html(
|
|
15
|
+
strings: TemplateStringsArray,
|
|
16
|
+
...expressions: unknown[]
|
|
17
|
+
): string {
|
|
18
|
+
// Join all string parts
|
|
19
|
+
let raw = strings[0] ?? ''
|
|
20
|
+
|
|
21
|
+
for (let i = 1, l = strings.length; i < l; i++) {
|
|
22
|
+
raw += expressions[i - 1]
|
|
23
|
+
raw += strings[i]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// dedent: remove common leading whitespace from all non-empty lines
|
|
27
|
+
const lines = raw.split('\n')
|
|
28
|
+
// Ignore empty lines and lines with only whitespace
|
|
29
|
+
const nonEmptyLines = lines.filter((line) => line.trim().length > 0)
|
|
30
|
+
const indentLengths = nonEmptyLines.map(
|
|
31
|
+
(line) => line.match(/^(\s*)/)![0].length,
|
|
32
|
+
)
|
|
33
|
+
const minIndent = indentLengths.length > 0 ? Math.min(...indentLengths) : 0
|
|
34
|
+
|
|
35
|
+
// Remove the common indent from all lines
|
|
36
|
+
const dedented = lines.map((line) => line.slice(minIndent)).join('\n')
|
|
37
|
+
|
|
38
|
+
// Trim leading/trailing newlines
|
|
39
|
+
return dedented.trim()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Helper to convert HTML to MDX string
|
|
43
|
+
async function htmlToMdxString({
|
|
44
|
+
markdown,
|
|
45
|
+
onError,
|
|
46
|
+
}: {
|
|
47
|
+
markdown: string
|
|
48
|
+
onError?: (error: unknown, text: string) => void
|
|
49
|
+
}): Promise<string> {
|
|
50
|
+
const remarkHtmlBlocks: Plugin<[], Root> = function () {
|
|
51
|
+
return (tree: Root) => {
|
|
52
|
+
visit(tree, (node, index, parent) => {
|
|
53
|
+
if (
|
|
54
|
+
node.type === 'html' &&
|
|
55
|
+
parent &&
|
|
56
|
+
typeof index === 'number'
|
|
57
|
+
) {
|
|
58
|
+
const htmlValue = node.value as string
|
|
59
|
+
|
|
60
|
+
// Parse HTML to MDX AST with processor for markdown parsing
|
|
61
|
+
const mdxNodes = parseHtmlToMdxAst({
|
|
62
|
+
html: htmlValue,
|
|
63
|
+
onError,
|
|
64
|
+
textToMdast: ({ text: x }) => {
|
|
65
|
+
const processor = remark().use(() => {
|
|
66
|
+
return (tree, file) => {
|
|
67
|
+
file.data.ast = tree
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const mdast = processor.parse(x) as any
|
|
72
|
+
processor.runSync(mdast)
|
|
73
|
+
return mdast
|
|
74
|
+
},
|
|
75
|
+
parentType: parent.type,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
// Replace the HTML node with the MDX nodes
|
|
79
|
+
if (mdxNodes.length === 1) {
|
|
80
|
+
parent.children[index] = mdxNodes[0]
|
|
81
|
+
} else if (mdxNodes.length > 1) {
|
|
82
|
+
parent.children.splice(index, 1, ...mdxNodes)
|
|
83
|
+
} else {
|
|
84
|
+
// Remove the node if no content
|
|
85
|
+
parent.children.splice(index, 1)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const processor = remark().use(remarkHtmlBlocks).use(remarkStringify, {})
|
|
93
|
+
|
|
94
|
+
const mdast = processor.parse(markdown)
|
|
95
|
+
processor.runSync(mdast)
|
|
96
|
+
return remark().use(remarkMdx).use(remarkStringify, {}).stringify(mdast)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe('Notion-specific HTML to MDX', () => {
|
|
100
|
+
test('converts page element to MDX with surrounding markdown', async () => {
|
|
101
|
+
const htmlContent = html`
|
|
102
|
+
<page url="{{https://notion.so/test}}">
|
|
103
|
+
Test Page
|
|
104
|
+
</page>
|
|
105
|
+
`
|
|
106
|
+
|
|
107
|
+
const markdown = `
|
|
108
|
+
# My Document
|
|
109
|
+
|
|
110
|
+
${htmlContent}
|
|
111
|
+
|
|
112
|
+
Some text after the page element.
|
|
113
|
+
`
|
|
114
|
+
|
|
115
|
+
const mdxString = await htmlToMdxString({
|
|
116
|
+
markdown,
|
|
117
|
+
onError: (e) => {
|
|
118
|
+
throw e
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
expect(mdxString).toMatchInlineSnapshot(`
|
|
122
|
+
"# My Document
|
|
123
|
+
|
|
124
|
+
<page url="{{https://notion.so/test}}">
|
|
125
|
+
Test Page
|
|
126
|
+
</page>
|
|
127
|
+
|
|
128
|
+
Some text after the page element.
|
|
129
|
+
"
|
|
130
|
+
`)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test('converts callout element to MDX with surrounding content', async () => {
|
|
134
|
+
const htmlContent = html`
|
|
135
|
+
<callout icon="📎" color="pink_bg">
|
|
136
|
+
Important note
|
|
137
|
+
</callout>
|
|
138
|
+
`
|
|
139
|
+
|
|
140
|
+
const markdown = `
|
|
141
|
+
Here's an important message:
|
|
142
|
+
|
|
143
|
+
${htmlContent}
|
|
144
|
+
|
|
145
|
+
**Bold text** after the callout.
|
|
146
|
+
`
|
|
147
|
+
|
|
148
|
+
const mdxString = await htmlToMdxString({
|
|
149
|
+
markdown,
|
|
150
|
+
onError: (e) => {
|
|
151
|
+
throw e
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
expect(mdxString).toMatchInlineSnapshot(`
|
|
155
|
+
"Here's an important message:
|
|
156
|
+
|
|
157
|
+
<callout icon="📎" color="pink_bg">
|
|
158
|
+
Important note
|
|
159
|
+
</callout>
|
|
160
|
+
|
|
161
|
+
**Bold text** after the callout.
|
|
162
|
+
"
|
|
163
|
+
`)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test('converts mention-page element to MDX with mixed content', async () => {
|
|
167
|
+
const htmlContent = html`
|
|
168
|
+
<mention-page url="{{https://notion.so/test}}" />
|
|
169
|
+
`
|
|
170
|
+
|
|
171
|
+
const markdown = `Check out this page: ${htmlContent} for more information.
|
|
172
|
+
|
|
173
|
+
- First item
|
|
174
|
+
- Second item`
|
|
175
|
+
|
|
176
|
+
const mdxString = await htmlToMdxString({
|
|
177
|
+
markdown,
|
|
178
|
+
onError: (e) => {
|
|
179
|
+
throw e
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
expect(mdxString).toMatchInlineSnapshot(`
|
|
183
|
+
"Check out this page: <mention-page url="{{https://notion.so/test}}" /> for more information.
|
|
184
|
+
|
|
185
|
+
* First item
|
|
186
|
+
* Second item
|
|
187
|
+
"
|
|
188
|
+
`)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('converts nested Notion elements to MDX', async () => {
|
|
192
|
+
const htmlContent = html`
|
|
193
|
+
<columns>
|
|
194
|
+
<column>
|
|
195
|
+
<page url="{{https://notion.so/page1}}">Page 1</page>
|
|
196
|
+
Some text
|
|
197
|
+
</column>
|
|
198
|
+
<column>
|
|
199
|
+
<callout icon="💡" color="yellow_bg">
|
|
200
|
+
Important callout
|
|
201
|
+
</callout>
|
|
202
|
+
</column>
|
|
203
|
+
</columns>
|
|
204
|
+
`
|
|
205
|
+
|
|
206
|
+
const mdxString = await htmlToMdxString({
|
|
207
|
+
markdown: htmlContent,
|
|
208
|
+
onError: (e) => {
|
|
209
|
+
throw e
|
|
210
|
+
},
|
|
211
|
+
})
|
|
212
|
+
expect(mdxString).toMatchInlineSnapshot(`
|
|
213
|
+
"<columns>
|
|
214
|
+
<column>
|
|
215
|
+
<page url="{{https://notion.so/page1}}">
|
|
216
|
+
Page 1
|
|
217
|
+
</page>
|
|
218
|
+
|
|
219
|
+
Some text
|
|
220
|
+
</column>
|
|
221
|
+
|
|
222
|
+
<column>
|
|
223
|
+
<callout icon="💡" color="yellow_bg">
|
|
224
|
+
Important callout
|
|
225
|
+
</callout>
|
|
226
|
+
</column>
|
|
227
|
+
</columns>
|
|
228
|
+
"
|
|
229
|
+
`)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('handles mixed HTML and Notion elements with surrounding markdown', async () => {
|
|
233
|
+
const htmlContent = html`
|
|
234
|
+
<div>
|
|
235
|
+
<h1>Title</h1>
|
|
236
|
+
<page url="{{https://notion.so/test}}">Test Page</page>
|
|
237
|
+
<p>Regular paragraph</p>
|
|
238
|
+
<mention-page url="{{https://notion.so/another}}" />
|
|
239
|
+
</div>
|
|
240
|
+
`
|
|
241
|
+
|
|
242
|
+
const markdown = `## Section Header
|
|
243
|
+
|
|
244
|
+
${htmlContent}
|
|
245
|
+
|
|
246
|
+
And here's a [link](https://example.com) after the HTML block.`
|
|
247
|
+
|
|
248
|
+
const mdxString = await htmlToMdxString({
|
|
249
|
+
markdown,
|
|
250
|
+
onError: (e) => {
|
|
251
|
+
throw e
|
|
252
|
+
},
|
|
253
|
+
})
|
|
254
|
+
expect(mdxString).toMatchInlineSnapshot(`
|
|
255
|
+
"## Section Header
|
|
256
|
+
|
|
257
|
+
<div>
|
|
258
|
+
<h1>
|
|
259
|
+
Title
|
|
260
|
+
</h1>
|
|
261
|
+
|
|
262
|
+
<page url="{{https://notion.so/test}}">
|
|
263
|
+
Test Page
|
|
264
|
+
</page>
|
|
265
|
+
|
|
266
|
+
<p>
|
|
267
|
+
Regular paragraph
|
|
268
|
+
</p>
|
|
269
|
+
|
|
270
|
+
<mention-page url="{{https://notion.so/another}}" />
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
And here's a [link](https://example.com) after the HTML block.
|
|
274
|
+
"
|
|
275
|
+
`)
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
test('converts span with color attribute', async () => {
|
|
279
|
+
const htmlContent = html`
|
|
280
|
+
<span color="blue">
|
|
281
|
+
Blue text
|
|
282
|
+
</span>
|
|
283
|
+
`
|
|
284
|
+
|
|
285
|
+
const mdxString = await htmlToMdxString({
|
|
286
|
+
markdown: htmlContent,
|
|
287
|
+
onError: (e) => {
|
|
288
|
+
throw e
|
|
289
|
+
},
|
|
290
|
+
})
|
|
291
|
+
expect(mdxString).toMatchInlineSnapshot(`
|
|
292
|
+
"<span color="blue">
|
|
293
|
+
Blue text
|
|
294
|
+
</span>
|
|
295
|
+
"
|
|
296
|
+
`)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
test('handles table element conversion', async () => {
|
|
300
|
+
const htmlContent = html`
|
|
301
|
+
<table header-row="true">
|
|
302
|
+
<tr>
|
|
303
|
+
<td>Cell 1</td>
|
|
304
|
+
<td>Cell 2</td>
|
|
305
|
+
</tr>
|
|
306
|
+
</table>
|
|
307
|
+
`
|
|
308
|
+
|
|
309
|
+
const mdxString = await htmlToMdxString({
|
|
310
|
+
markdown: htmlContent,
|
|
311
|
+
onError: (e) => {
|
|
312
|
+
throw e
|
|
313
|
+
},
|
|
314
|
+
})
|
|
315
|
+
expect(mdxString).toMatchInlineSnapshot(`
|
|
316
|
+
"<table header-row="true">
|
|
317
|
+
<tr>
|
|
318
|
+
<td>
|
|
319
|
+
Cell 1
|
|
320
|
+
</td>
|
|
321
|
+
|
|
322
|
+
<td>
|
|
323
|
+
Cell 2
|
|
324
|
+
</td>
|
|
325
|
+
</tr>
|
|
326
|
+
</table>
|
|
327
|
+
"
|
|
328
|
+
`)
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
test('handles image element conversion', async () => {
|
|
332
|
+
const htmlContent = html`
|
|
333
|
+
<image
|
|
334
|
+
source="{{https://example.com/image.jpg}}"
|
|
335
|
+
alt="Test image"
|
|
336
|
+
/>
|
|
337
|
+
`
|
|
338
|
+
|
|
339
|
+
const mdxString = await htmlToMdxString({
|
|
340
|
+
markdown: htmlContent,
|
|
341
|
+
onError: (e) => {
|
|
342
|
+
throw e
|
|
343
|
+
},
|
|
344
|
+
})
|
|
345
|
+
expect(mdxString).toMatchInlineSnapshot(`
|
|
346
|
+
"<image source="{{https://example.com/image.jpg}}" alt="Test image" />
|
|
347
|
+
"
|
|
348
|
+
`)
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
test('handles unknown element conversion', async () => {
|
|
352
|
+
const htmlContent = html`
|
|
353
|
+
<unknown
|
|
354
|
+
url="{{https://notion.so/embed}}"
|
|
355
|
+
alt="embed"
|
|
356
|
+
/>
|
|
357
|
+
`
|
|
358
|
+
|
|
359
|
+
const mdxString = await htmlToMdxString({
|
|
360
|
+
markdown: htmlContent,
|
|
361
|
+
onError: (e) => {
|
|
362
|
+
throw e
|
|
363
|
+
},
|
|
364
|
+
})
|
|
365
|
+
expect(mdxString).toMatchInlineSnapshot(`
|
|
366
|
+
"<unknown url="{{https://notion.so/embed}}" alt="embed" />
|
|
367
|
+
"
|
|
368
|
+
`)
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
test('handles columns with content and surrounding markdown', async () => {
|
|
372
|
+
const htmlContent = html`
|
|
373
|
+
<columns>
|
|
374
|
+
<column>
|
|
375
|
+
<h2>Section 1</h2>
|
|
376
|
+
<page url="{{https://notion.so/page}}">Page Link</page>
|
|
377
|
+
</column>
|
|
378
|
+
<column>
|
|
379
|
+
<callout icon="⚠️" color="yellow_bg">
|
|
380
|
+
<strong>Warning:</strong> Important information
|
|
381
|
+
</callout>
|
|
382
|
+
</column>
|
|
383
|
+
</columns>
|
|
384
|
+
`
|
|
385
|
+
|
|
386
|
+
const markdown = `# Main Title
|
|
387
|
+
|
|
388
|
+
Here's some introductory text before the columns.
|
|
389
|
+
|
|
390
|
+
${htmlContent}
|
|
391
|
+
|
|
392
|
+
---
|
|
393
|
+
|
|
394
|
+
Footer text with **bold** and *italic*.`
|
|
395
|
+
|
|
396
|
+
const mdxString = await htmlToMdxString({
|
|
397
|
+
markdown,
|
|
398
|
+
onError: (e) => {
|
|
399
|
+
throw e
|
|
400
|
+
},
|
|
401
|
+
})
|
|
402
|
+
expect(mdxString).toMatchInlineSnapshot(`
|
|
403
|
+
"# Main Title
|
|
404
|
+
|
|
405
|
+
Here's some introductory text before the columns.
|
|
406
|
+
|
|
407
|
+
<columns>
|
|
408
|
+
<column>
|
|
409
|
+
<h2>
|
|
410
|
+
Section 1
|
|
411
|
+
</h2>
|
|
412
|
+
|
|
413
|
+
<page url="{{https://notion.so/page}}">
|
|
414
|
+
Page Link
|
|
415
|
+
</page>
|
|
416
|
+
</column>
|
|
417
|
+
|
|
418
|
+
<column>
|
|
419
|
+
<callout icon="⚠️" color="yellow_bg">
|
|
420
|
+
<strong>
|
|
421
|
+
Warning:
|
|
422
|
+
</strong>
|
|
423
|
+
|
|
424
|
+
Important information
|
|
425
|
+
</callout>
|
|
426
|
+
</column>
|
|
427
|
+
</columns>
|
|
428
|
+
|
|
429
|
+
***
|
|
430
|
+
|
|
431
|
+
Footer text with **bold** and *italic*.
|
|
432
|
+
"
|
|
433
|
+
`)
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
test('handles HTML wrappers around markdown content', async () => {
|
|
437
|
+
|
|
438
|
+
// TODO if ypu do not add a new line after <selfClosingTag /> it gets all parsed as html!
|
|
439
|
+
const markdown = html`
|
|
440
|
+
<table-of-contents color="gray" />
|
|
441
|
+
|
|
442
|
+
## GitHub/GitLab: Update issues with pull request actions
|
|
443
|
+
The GitHub and GitLab integrations move issues from *In Progress* to *Done* automatically so you never have to update issues manually. It takes less than a minute to connect GitHub to the workspace and then go to team settings to configure the automatic updates. Read more in the detailed [documentation]({{/60b0cf80dbe0420faa1264a58da48bd2}}).
|
|
444
|
+
<unknown url="{{https://www.notion.so/f050b7b5625b40c1a67ec00d8523dca8#68722a306eb646ffac1a6bf590b654f6}}" alt="tweet" />
|
|
445
|
+
### ✨ProTip: Set personal GitHub preferences
|
|
446
|
+
Configure these settings in Preferences under Account Settings.
|
|
447
|
+
`
|
|
448
|
+
|
|
449
|
+
const mdxString = await htmlToMdxString({
|
|
450
|
+
markdown,
|
|
451
|
+
onError: (e) => {
|
|
452
|
+
throw e
|
|
453
|
+
},
|
|
454
|
+
})
|
|
455
|
+
expect(mdxString).toMatchInlineSnapshot(`
|
|
456
|
+
"<table-of-contents color="gray" />
|
|
457
|
+
|
|
458
|
+
## GitHub/GitLab: Update issues with pull request actions
|
|
459
|
+
|
|
460
|
+
The GitHub and GitLab integrations move issues from *In Progress* to *Done* automatically so you never have to update issues manually. It takes less than a minute to connect GitHub to the workspace and then go to team settings to configure the automatic updates. Read more in the detailed [documentation](\\{\\{/60b0cf80dbe0420faa1264a58da48bd2}}).
|
|
461
|
+
<unknown url="{{https://www.notion.so/f050b7b5625b40c1a67ec00d8523dca8#68722a306eb646ffac1a6bf590b654f6}}" alt="tweet" />
|
|
462
|
+
|
|
463
|
+
### ✨ProTip: Set personal GitHub preferences
|
|
464
|
+
|
|
465
|
+
Configure these settings in Preferences under Account Settings.
|
|
466
|
+
"
|
|
467
|
+
`)
|
|
468
|
+
})
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
describe('parseHtmlToMdxAst', () => {
|
|
472
|
+
test('parses simple HTML element', () => {
|
|
473
|
+
const result = parseHtmlToMdxAst({ html: '<div>Hello</div>' })
|
|
474
|
+
expect(result).toMatchInlineSnapshot(`
|
|
475
|
+
[
|
|
476
|
+
{
|
|
477
|
+
"attributes": [],
|
|
478
|
+
"children": [
|
|
479
|
+
{
|
|
480
|
+
"type": "text",
|
|
481
|
+
"value": "Hello",
|
|
482
|
+
},
|
|
483
|
+
],
|
|
484
|
+
"name": "div",
|
|
485
|
+
"type": "mdxJsxTextElement",
|
|
486
|
+
},
|
|
487
|
+
]
|
|
488
|
+
`)
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
test('parses element without transforms (generic)', () => {
|
|
492
|
+
const result = parseHtmlToMdxAst({
|
|
493
|
+
html: '<page url="{{https://notion.so/test}}">Test Page</page>',
|
|
494
|
+
})
|
|
495
|
+
expect(result).toMatchInlineSnapshot(`
|
|
496
|
+
[
|
|
497
|
+
{
|
|
498
|
+
"attributes": [
|
|
499
|
+
{
|
|
500
|
+
"name": "url",
|
|
501
|
+
"type": "mdxJsxAttribute",
|
|
502
|
+
"value": "{{https://notion.so/test}}",
|
|
503
|
+
},
|
|
504
|
+
],
|
|
505
|
+
"children": [
|
|
506
|
+
{
|
|
507
|
+
"type": "text",
|
|
508
|
+
"value": "Test Page",
|
|
509
|
+
},
|
|
510
|
+
],
|
|
511
|
+
"name": "page",
|
|
512
|
+
"type": "mdxJsxTextElement",
|
|
513
|
+
},
|
|
514
|
+
]
|
|
515
|
+
`)
|
|
516
|
+
})
|
|
517
|
+
|
|
518
|
+
test('parses Notion page element', () => {
|
|
519
|
+
const result = parseHtmlToMdxAst({
|
|
520
|
+
html: '<page url="{{https://www.notion.so/test}}">Test Page</page>',
|
|
521
|
+
})
|
|
522
|
+
expect(result).toMatchInlineSnapshot(`
|
|
523
|
+
[
|
|
524
|
+
{
|
|
525
|
+
"attributes": [
|
|
526
|
+
{
|
|
527
|
+
"name": "url",
|
|
528
|
+
"type": "mdxJsxAttribute",
|
|
529
|
+
"value": "{{https://www.notion.so/test}}",
|
|
530
|
+
},
|
|
531
|
+
],
|
|
532
|
+
"children": [
|
|
533
|
+
{
|
|
534
|
+
"type": "text",
|
|
535
|
+
"value": "Test Page",
|
|
536
|
+
},
|
|
537
|
+
],
|
|
538
|
+
"name": "page",
|
|
539
|
+
"type": "mdxJsxTextElement",
|
|
540
|
+
},
|
|
541
|
+
]
|
|
542
|
+
`)
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
test('handles partial HTML - opening tag only', () => {
|
|
546
|
+
const result = parseHtmlToMdxAst({ html: '<div>' })
|
|
547
|
+
expect(result).toMatchInlineSnapshot(`
|
|
548
|
+
[
|
|
549
|
+
{
|
|
550
|
+
"attributes": [],
|
|
551
|
+
"children": [],
|
|
552
|
+
"name": "div",
|
|
553
|
+
"type": "mdxJsxTextElement",
|
|
554
|
+
},
|
|
555
|
+
]
|
|
556
|
+
`)
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
test('handles partial HTML - closing tag only', () => {
|
|
560
|
+
const result = parseHtmlToMdxAst({ html: '</div>' })
|
|
561
|
+
expect(result).toMatchInlineSnapshot(`
|
|
562
|
+
[]
|
|
563
|
+
`)
|
|
564
|
+
})
|
|
565
|
+
|
|
566
|
+
test('handles self-closing tags', () => {
|
|
567
|
+
const result = parseHtmlToMdxAst({
|
|
568
|
+
html: '<img source="{{https://example.com/img.jpg}}" />',
|
|
569
|
+
})
|
|
570
|
+
expect(result).toMatchInlineSnapshot(`
|
|
571
|
+
[
|
|
572
|
+
{
|
|
573
|
+
"attributes": [
|
|
574
|
+
{
|
|
575
|
+
"name": "source",
|
|
576
|
+
"type": "mdxJsxAttribute",
|
|
577
|
+
"value": "{{https://example.com/img.jpg}}",
|
|
578
|
+
},
|
|
579
|
+
],
|
|
580
|
+
"children": [],
|
|
581
|
+
"name": "img",
|
|
582
|
+
"type": "mdxJsxTextElement",
|
|
583
|
+
},
|
|
584
|
+
]
|
|
585
|
+
`)
|
|
586
|
+
})
|
|
587
|
+
|
|
588
|
+
test('handles mention-page element', () => {
|
|
589
|
+
const result = parseHtmlToMdxAst({
|
|
590
|
+
html: '<mention-page url="{{https://www.notion.so/test}}" />',
|
|
591
|
+
})
|
|
592
|
+
expect(result).toMatchInlineSnapshot(`
|
|
593
|
+
[
|
|
594
|
+
{
|
|
595
|
+
"attributes": [
|
|
596
|
+
{
|
|
597
|
+
"name": "url",
|
|
598
|
+
"type": "mdxJsxAttribute",
|
|
599
|
+
"value": "{{https://www.notion.so/test}}",
|
|
600
|
+
},
|
|
601
|
+
],
|
|
602
|
+
"children": [],
|
|
603
|
+
"name": "mention-page",
|
|
604
|
+
"type": "mdxJsxTextElement",
|
|
605
|
+
},
|
|
606
|
+
]
|
|
607
|
+
`)
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
test('handles callout with attributes', () => {
|
|
611
|
+
const result = parseHtmlToMdxAst({
|
|
612
|
+
html: '<callout icon="📎" color="pink_bg">Some text</callout>',
|
|
613
|
+
})
|
|
614
|
+
expect(result).toMatchInlineSnapshot(`
|
|
615
|
+
[
|
|
616
|
+
{
|
|
617
|
+
"attributes": [
|
|
618
|
+
{
|
|
619
|
+
"name": "icon",
|
|
620
|
+
"type": "mdxJsxAttribute",
|
|
621
|
+
"value": "📎",
|
|
622
|
+
},
|
|
623
|
+
{
|
|
624
|
+
"name": "color",
|
|
625
|
+
"type": "mdxJsxAttribute",
|
|
626
|
+
"value": "pink_bg",
|
|
627
|
+
},
|
|
628
|
+
],
|
|
629
|
+
"children": [
|
|
630
|
+
{
|
|
631
|
+
"type": "text",
|
|
632
|
+
"value": "Some text",
|
|
633
|
+
},
|
|
634
|
+
],
|
|
635
|
+
"name": "callout",
|
|
636
|
+
"type": "mdxJsxTextElement",
|
|
637
|
+
},
|
|
638
|
+
]
|
|
639
|
+
`)
|
|
640
|
+
})
|
|
641
|
+
|
|
642
|
+
test('handles span with color', () => {
|
|
643
|
+
const result = parseHtmlToMdxAst({
|
|
644
|
+
html: '<span color="blue">colored text</span>',
|
|
645
|
+
})
|
|
646
|
+
expect(result).toMatchInlineSnapshot(`
|
|
647
|
+
[
|
|
648
|
+
{
|
|
649
|
+
"attributes": [
|
|
650
|
+
{
|
|
651
|
+
"name": "color",
|
|
652
|
+
"type": "mdxJsxAttribute",
|
|
653
|
+
"value": "blue",
|
|
654
|
+
},
|
|
655
|
+
],
|
|
656
|
+
"children": [
|
|
657
|
+
{
|
|
658
|
+
"type": "text",
|
|
659
|
+
"value": "colored text",
|
|
660
|
+
},
|
|
661
|
+
],
|
|
662
|
+
"name": "span",
|
|
663
|
+
"type": "mdxJsxTextElement",
|
|
664
|
+
},
|
|
665
|
+
]
|
|
666
|
+
`)
|
|
667
|
+
})
|
|
668
|
+
|
|
669
|
+
test('handles mixed content', () => {
|
|
670
|
+
const result = parseHtmlToMdxAst({
|
|
671
|
+
html: 'Some text <page url="{{https://notion.so/test}}">Page</page> more text',
|
|
672
|
+
})
|
|
673
|
+
expect(result).toMatchInlineSnapshot(`
|
|
674
|
+
[
|
|
675
|
+
{
|
|
676
|
+
"type": "text",
|
|
677
|
+
"value": "Some text",
|
|
678
|
+
},
|
|
679
|
+
{
|
|
680
|
+
"attributes": [
|
|
681
|
+
{
|
|
682
|
+
"name": "url",
|
|
683
|
+
"type": "mdxJsxAttribute",
|
|
684
|
+
"value": "{{https://notion.so/test}}",
|
|
685
|
+
},
|
|
686
|
+
],
|
|
687
|
+
"children": [
|
|
688
|
+
{
|
|
689
|
+
"type": "text",
|
|
690
|
+
"value": "Page",
|
|
691
|
+
},
|
|
692
|
+
],
|
|
693
|
+
"name": "page",
|
|
694
|
+
"type": "mdxJsxTextElement",
|
|
695
|
+
},
|
|
696
|
+
{
|
|
697
|
+
"type": "text",
|
|
698
|
+
"value": "more text",
|
|
699
|
+
},
|
|
700
|
+
]
|
|
701
|
+
`)
|
|
702
|
+
})
|
|
703
|
+
|
|
704
|
+
test('handles comments', () => {
|
|
705
|
+
const result = parseHtmlToMdxAst({ html: '<!-- This is a comment -->' })
|
|
706
|
+
expect(result).toMatchInlineSnapshot(`[]`)
|
|
707
|
+
})
|
|
708
|
+
|
|
709
|
+
test('handles table with attributes', () => {
|
|
710
|
+
const result = parseHtmlToMdxAst({
|
|
711
|
+
html: '<table header-row="true"><tr><td>Cell</td></tr></table>',
|
|
712
|
+
})
|
|
713
|
+
expect(result).toMatchInlineSnapshot(`
|
|
714
|
+
[
|
|
715
|
+
{
|
|
716
|
+
"attributes": [
|
|
717
|
+
{
|
|
718
|
+
"name": "header-row",
|
|
719
|
+
"type": "mdxJsxAttribute",
|
|
720
|
+
"value": "true",
|
|
721
|
+
},
|
|
722
|
+
],
|
|
723
|
+
"children": [
|
|
724
|
+
{
|
|
725
|
+
"attributes": [],
|
|
726
|
+
"children": [
|
|
727
|
+
{
|
|
728
|
+
"attributes": [],
|
|
729
|
+
"children": [
|
|
730
|
+
{
|
|
731
|
+
"type": "text",
|
|
732
|
+
"value": "Cell",
|
|
733
|
+
},
|
|
734
|
+
],
|
|
735
|
+
"name": "td",
|
|
736
|
+
"type": "mdxJsxTextElement",
|
|
737
|
+
},
|
|
738
|
+
],
|
|
739
|
+
"name": "tr",
|
|
740
|
+
"type": "mdxJsxTextElement",
|
|
741
|
+
},
|
|
742
|
+
],
|
|
743
|
+
"name": "table",
|
|
744
|
+
"type": "mdxJsxTextElement",
|
|
745
|
+
},
|
|
746
|
+
]
|
|
747
|
+
`)
|
|
748
|
+
})
|
|
749
|
+
})
|
|
750
|
+
|
|
751
|
+
describe('parseHtmlToMdxAst without transforms (generic)', () => {
|
|
752
|
+
test('preserves tag names without transform', () => {
|
|
753
|
+
const result = parseHtmlToMdxAst({ html: '<page>Content</page>' })
|
|
754
|
+
expect(result[0]).toHaveProperty('name', 'page')
|
|
755
|
+
})
|
|
756
|
+
|
|
757
|
+
test('preserves curly brace URLs without transform', () => {
|
|
758
|
+
const result = parseHtmlToMdxAst({
|
|
759
|
+
html: '<a href="{{https://example.com}}">Link</a>',
|
|
760
|
+
})
|
|
761
|
+
expect(result[0]).toHaveProperty('attributes')
|
|
762
|
+
const attrs = (result[0] as any).attributes
|
|
763
|
+
expect(attrs[0]).toHaveProperty('value', '{{https://example.com}}')
|
|
764
|
+
})
|
|
765
|
+
})
|
|
766
|
+
|
|
767
|
+
describe('parseHtmlToMdxAst with markdown processor', () => {
|
|
768
|
+
test('parses markdown inside HTML tags', async () => {
|
|
769
|
+
const htmlContent = html`
|
|
770
|
+
<callout>
|
|
771
|
+
This is **bold** text
|
|
772
|
+
</callout>
|
|
773
|
+
`
|
|
774
|
+
|
|
775
|
+
const mdxString = await htmlToMdxString({
|
|
776
|
+
markdown: htmlContent,
|
|
777
|
+
onError: (e) => {
|
|
778
|
+
throw e
|
|
779
|
+
},
|
|
780
|
+
})
|
|
781
|
+
expect(mdxString).toMatchInlineSnapshot(`
|
|
782
|
+
"<callout>
|
|
783
|
+
This is **bold** text
|
|
784
|
+
</callout>
|
|
785
|
+
"
|
|
786
|
+
`)
|
|
787
|
+
})
|
|
788
|
+
|
|
789
|
+
test('parses markdown links inside HTML', async () => {
|
|
790
|
+
const htmlContent = html`
|
|
791
|
+
<span color="orange">
|
|
792
|
+
[link](http://google.com)
|
|
793
|
+
</span>
|
|
794
|
+
`
|
|
795
|
+
|
|
796
|
+
const mdxString = await htmlToMdxString({
|
|
797
|
+
markdown: htmlContent,
|
|
798
|
+
onError: (e) => {
|
|
799
|
+
throw e
|
|
800
|
+
},
|
|
801
|
+
})
|
|
802
|
+
expect(mdxString).toMatchInlineSnapshot(`
|
|
803
|
+
"<span color="orange">
|
|
804
|
+
[link](http://google.com)
|
|
805
|
+
</span>
|
|
806
|
+
"
|
|
807
|
+
`)
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
test('parses mixed markdown and HTML inside tags', async () => {
|
|
811
|
+
const htmlContent = html`
|
|
812
|
+
<callout>
|
|
813
|
+
**Read next:** <mention-page url="https://notion.so/page"/>
|
|
814
|
+
</callout>
|
|
815
|
+
`
|
|
816
|
+
|
|
817
|
+
const mdxString = await htmlToMdxString({
|
|
818
|
+
markdown: htmlContent,
|
|
819
|
+
onError: (e) => {
|
|
820
|
+
throw e
|
|
821
|
+
},
|
|
822
|
+
})
|
|
823
|
+
expect(mdxString).toMatchInlineSnapshot(`
|
|
824
|
+
"<callout>
|
|
825
|
+
**Read next:**
|
|
826
|
+
|
|
827
|
+
<mention-page url="https://notion.so/page" />
|
|
828
|
+
</callout>
|
|
829
|
+
"
|
|
830
|
+
`)
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
test('handles bold inside span with underline', async () => {
|
|
834
|
+
const htmlContent = html`
|
|
835
|
+
<span underline="true">
|
|
836
|
+
**sdf dsf**
|
|
837
|
+
</span>
|
|
838
|
+
`
|
|
839
|
+
|
|
840
|
+
const mdxString = await htmlToMdxString({
|
|
841
|
+
markdown: htmlContent,
|
|
842
|
+
onError: (e) => {
|
|
843
|
+
throw e
|
|
844
|
+
},
|
|
845
|
+
})
|
|
846
|
+
expect(mdxString).toMatchInlineSnapshot(`
|
|
847
|
+
"<span underline="true">
|
|
848
|
+
**sdf dsf**
|
|
849
|
+
</span>
|
|
850
|
+
"
|
|
851
|
+
`)
|
|
852
|
+
})
|
|
853
|
+
|
|
854
|
+
test('converts markdown inside callout to MDX string', async () => {
|
|
855
|
+
const htmlContent = html`
|
|
856
|
+
<callout icon="👉" color="orange_bg">
|
|
857
|
+
**Read next:** Some page
|
|
858
|
+
</callout>
|
|
859
|
+
`
|
|
860
|
+
|
|
861
|
+
const mdxString = await htmlToMdxString({
|
|
862
|
+
markdown: htmlContent,
|
|
863
|
+
onError: (e) => {
|
|
864
|
+
throw e
|
|
865
|
+
},
|
|
866
|
+
})
|
|
867
|
+
expect(mdxString).toMatchInlineSnapshot(`
|
|
868
|
+
"<callout icon="👉" color="orange_bg">
|
|
869
|
+
**Read next:** Some page
|
|
870
|
+
</callout>
|
|
871
|
+
"
|
|
872
|
+
`)
|
|
873
|
+
})
|
|
874
|
+
|
|
875
|
+
test('handles markdown inside table cells', async () => {
|
|
876
|
+
const htmlContent = html`
|
|
877
|
+
<table>
|
|
878
|
+
<tr>
|
|
879
|
+
<td>
|
|
880
|
+
**Bold** text and [link](http://example.com)
|
|
881
|
+
</td>
|
|
882
|
+
</tr>
|
|
883
|
+
</table>
|
|
884
|
+
`
|
|
885
|
+
|
|
886
|
+
const mdxString = await htmlToMdxString({
|
|
887
|
+
markdown: htmlContent,
|
|
888
|
+
onError: (e) => {
|
|
889
|
+
throw e
|
|
890
|
+
},
|
|
891
|
+
})
|
|
892
|
+
expect(mdxString).toMatchInlineSnapshot(`
|
|
893
|
+
"<table>
|
|
894
|
+
<tr>
|
|
895
|
+
<td>
|
|
896
|
+
**Bold** text and [link](http://example.com)
|
|
897
|
+
</td>
|
|
898
|
+
</tr>
|
|
899
|
+
</table>
|
|
900
|
+
"
|
|
901
|
+
`)
|
|
902
|
+
})
|
|
903
|
+
|
|
904
|
+
test('preserves plain text when no markdown', async () => {
|
|
905
|
+
const htmlContent = html`
|
|
906
|
+
<div>
|
|
907
|
+
Plain text without markdown
|
|
908
|
+
</div>
|
|
909
|
+
`
|
|
910
|
+
|
|
911
|
+
const mdxString = await htmlToMdxString({
|
|
912
|
+
markdown: htmlContent,
|
|
913
|
+
onError: (e) => {
|
|
914
|
+
throw e
|
|
915
|
+
},
|
|
916
|
+
})
|
|
917
|
+
expect(mdxString).toMatchInlineSnapshot(`
|
|
918
|
+
"<div>
|
|
919
|
+
Plain text without markdown
|
|
920
|
+
</div>
|
|
921
|
+
"
|
|
922
|
+
`)
|
|
923
|
+
})
|
|
924
|
+
|
|
925
|
+
test('handles nested HTML tags with markdown', async () => {
|
|
926
|
+
const htmlContent = html`
|
|
927
|
+
<div>
|
|
928
|
+
<span>
|
|
929
|
+
**Bold** and <a href="#">link</a>
|
|
930
|
+
</span>
|
|
931
|
+
</div>
|
|
932
|
+
`
|
|
933
|
+
|
|
934
|
+
const mdxString = await htmlToMdxString({
|
|
935
|
+
markdown: htmlContent,
|
|
936
|
+
onError: (e) => {
|
|
937
|
+
throw e
|
|
938
|
+
},
|
|
939
|
+
})
|
|
940
|
+
expect(mdxString).toMatchInlineSnapshot(`
|
|
941
|
+
"<div>
|
|
942
|
+
<span>
|
|
943
|
+
**Bold** and
|
|
944
|
+
|
|
945
|
+
<a href="#">
|
|
946
|
+
link
|
|
947
|
+
</a>
|
|
948
|
+
</span>
|
|
949
|
+
</div>
|
|
950
|
+
"
|
|
951
|
+
`)
|
|
952
|
+
})
|
|
953
|
+
})
|