safe-mdx 1.0.3 → 1.1.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/src/safe-mdx.tsx CHANGED
@@ -1,10 +1,10 @@
1
- import React, { use, cloneElement, Suspense } from 'react'
1
+ import React, { Suspense, cloneElement } from 'react'
2
2
 
3
+ import type { StandardSchemaV1 } from '@standard-schema/spec'
3
4
  import type { Node, Parent, Root, RootContent } from 'mdast'
4
5
  import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx-jsx'
5
6
 
6
7
  import { Fragment, ReactNode } from 'react'
7
- import { completeJsxTags } from './streaming.js'
8
8
 
9
9
  const HtmlToJsxConverter = React.lazy(() =>
10
10
  import('./HtmlToJsxConverter.js').then((module) => ({
@@ -23,27 +23,47 @@ declare module 'mdast' {
23
23
  }
24
24
  }
25
25
 
26
- export type CustomTransformer = (
26
+ export type RenderNode = (
27
27
  node: MyRootContent,
28
28
  transform: (node: MyRootContent) => ReactNode,
29
29
  ) => ReactNode | undefined
30
30
 
31
+ export interface SafeMdxError {
32
+ message: string
33
+ line?: number
34
+ schemaPath?: string
35
+ }
36
+
37
+ export type ComponentPropsSchema = Record<string, StandardSchemaV1>
38
+
39
+ export type CreateElementFunction = (
40
+ type: any,
41
+ props?: any,
42
+ ...children: ReactNode[]
43
+ ) => ReactNode
44
+
31
45
  export const SafeMdxRenderer = React.memo(function SafeMdxRenderer({
32
46
  components,
33
47
  markdown = '',
34
48
  mdast = null as any,
35
49
  renderNode,
50
+ componentPropsSchema,
51
+ createElement,
36
52
  }: {
37
53
  components?: ComponentsMap
38
54
  markdown?: string
39
55
  mdast: MyRootContent
40
- renderNode?: CustomTransformer
56
+ renderNode?: RenderNode
57
+ componentPropsSchema?: ComponentPropsSchema
58
+ createElement?: CreateElementFunction
41
59
  }) {
42
60
  const visitor = new MdastToJsx({
43
61
  markdown,
44
62
  mdast,
45
63
  components,
46
64
  renderNode,
65
+ componentPropsSchema,
66
+ createElement,
47
67
  })
48
68
  const result = visitor.run()
49
69
  return result
@@ -54,14 +74,18 @@ export class MdastToJsx {
54
74
  str: string
55
75
  jsxStr: string = ''
56
76
  c: ComponentsMap
57
- errors: { message: string }[] = []
58
- renderNode?: CustomTransformer
77
+ errors: SafeMdxError[] = []
78
+ renderNode?: RenderNode
79
+ componentPropsSchema?: ComponentPropsSchema
80
+ createElement: CreateElementFunction
59
81
 
60
82
  constructor({
61
83
  markdown: code = '',
62
84
  mdast,
63
85
  components = {} as ComponentsMap,
64
86
  renderNode,
87
+ componentPropsSchema,
88
+ createElement = React.createElement,
65
89
  }: {
66
90
  markdown?: string
67
91
  mdast: MyRootContent
@@ -70,6 +94,8 @@ export class MdastToJsx {
70
94
  node: MyRootContent,
71
95
  transform: (node: MyRootContent) => ReactNode,
72
96
  ) => ReactNode | undefined
97
+ componentPropsSchema?: ComponentPropsSchema
98
+ createElement?: CreateElementFunction
73
99
  }) {
74
100
  this.str = code
75
101
 
@@ -77,6 +103,10 @@ export class MdastToJsx {
77
103
 
78
104
  this.renderNode = renderNode
79
105
 
106
+ this.componentPropsSchema = componentPropsSchema
107
+
108
+ this.createElement = createElement
109
+
80
110
  this.c = {
81
111
  ...Object.fromEntries(
82
112
  nativeTags.map((tag) => {
@@ -86,6 +116,36 @@ export class MdastToJsx {
86
116
  ...components,
87
117
  }
88
118
  }
119
+
120
+ validateComponentProps(
121
+ componentName: string,
122
+ props: Record<string, any>,
123
+ line?: number
124
+ ): void {
125
+ if (!this.componentPropsSchema || !this.componentPropsSchema[componentName]) {
126
+ return
127
+ }
128
+
129
+ const schema = this.componentPropsSchema[componentName]
130
+ let result = schema['~standard'].validate(props)
131
+
132
+ if (result instanceof Promise) {
133
+ // Ignore async validation errors as requested
134
+ return
135
+ } else {
136
+ if (result.issues) {
137
+ result.issues.forEach((issue) => {
138
+ const propPath = issue.path?.join('.') || 'unknown'
139
+ this.errors.push({
140
+ message: `Invalid props for component "${componentName}" at "${propPath}": ${issue.message}`,
141
+ line,
142
+ schemaPath: issue.path?.join('.'),
143
+ })
144
+ })
145
+ }
146
+ }
147
+ }
148
+
89
149
  mapMdastChildren(node: any) {
90
150
  const res = node.children
91
151
  ?.flatMap((child) => this.mdastTransformer(child))
@@ -137,6 +197,7 @@ export class MdastToJsx {
137
197
  if (!Component) {
138
198
  this.errors.push({
139
199
  message: `Unsupported jsx component ${node.name}`,
200
+ line: node.position?.start?.line,
140
201
  })
141
202
  return null
142
203
  }
@@ -146,11 +207,11 @@ export class MdastToJsx {
146
207
  })
147
208
 
148
209
  let attrs = Object.fromEntries(attrsList)
149
- return (
150
- <Component {...attrs}>
151
- {this.mapJsxChildren(node)}
152
- </Component>
153
- )
210
+
211
+ // Validate component props with schema if available
212
+ this.validateComponentProps(node.name, attrs, node.position?.start?.line)
213
+
214
+ return this.createElement(Component, attrs, this.mapJsxChildren(node))
154
215
  }
155
216
  default: {
156
217
  return this.mdastTransformer(node)
@@ -226,28 +287,16 @@ export class MdastToJsx {
226
287
  const level = node.depth
227
288
  const Tag = this.c[`h${level}`] ?? `h${level}`
228
289
 
229
- return (
230
- <Tag {...node.data?.hProperties}>
231
- {this.mapMdastChildren(node)}
232
- </Tag>
233
- )
290
+ return this.createElement(Tag, node.data?.hProperties, this.mapMdastChildren(node))
234
291
  }
235
292
  case 'paragraph': {
236
- return (
237
- <this.c.p {...node.data?.hProperties}>
238
- {this.mapMdastChildren(node)}
239
- </this.c.p>
240
- )
293
+ return this.createElement(this.c.p, node.data?.hProperties, this.mapMdastChildren(node))
241
294
  }
242
295
  case 'blockquote': {
243
- return (
244
- <this.c.blockquote {...node.data?.hProperties}>
245
- {this.mapMdastChildren(node)}
246
- </this.c.blockquote>
247
- )
296
+ return this.createElement(this.c.blockquote, node.data?.hProperties, this.mapMdastChildren(node))
248
297
  }
249
298
  case 'thematicBreak': {
250
- return <this.c.hr {...node.data?.hProperties} />
299
+ return this.createElement(this.c.hr, node.data?.hProperties)
251
300
  }
252
301
  case 'code': {
253
302
  if (!node.value) {
@@ -255,10 +304,10 @@ export class MdastToJsx {
255
304
  }
256
305
  const language = node.lang || ''
257
306
  const code = node.value
258
- const codeBlock = (className?: string) => (
259
- <this.c.pre {...node.data?.hProperties}>
260
- <this.c.code className={className}>{code}</this.c.code>
261
- </this.c.pre>
307
+ const codeBlock = (className?: string) => this.createElement(
308
+ this.c.pre,
309
+ node.data?.hProperties,
310
+ this.createElement(this.c.code, { className }, code)
262
311
  )
263
312
 
264
313
  if (language) {
@@ -269,49 +318,31 @@ export class MdastToJsx {
269
318
 
270
319
  case 'list': {
271
320
  if (node.ordered) {
272
- return (
273
- <this.c.ol
274
- start={node.start!}
275
- {...node.data?.hProperties}
276
- >
277
- {this.mapMdastChildren(node)}
278
- </this.c.ol>
321
+ return this.createElement(
322
+ this.c.ol,
323
+ { start: node.start!, ...node.data?.hProperties },
324
+ this.mapMdastChildren(node)
279
325
  )
280
326
  }
281
- return (
282
- <this.c.ul {...node.data?.hProperties}>
283
- {this.mapMdastChildren(node)}
284
- </this.c.ul>
285
- )
327
+ return this.createElement(this.c.ul, node.data?.hProperties, this.mapMdastChildren(node))
286
328
  }
287
329
  case 'listItem': {
288
330
  // https://github.com/syntax-tree/mdast-util-gfm-task-list-item#syntax-tree
289
331
  if (node?.checked != null) {
290
- return (
291
- <this.c.li
292
- data-checked={node.checked}
293
- {...node.data?.hProperties}
294
- >
295
- {this.mapMdastChildren(node)}
296
- </this.c.li>
332
+ return this.createElement(
333
+ this.c.li,
334
+ { 'data-checked': node.checked, ...node.data?.hProperties },
335
+ this.mapMdastChildren(node)
297
336
  )
298
337
  }
299
- return (
300
- <this.c.li {...node.data?.hProperties}>
301
- {this.mapMdastChildren(node)}
302
- </this.c.li>
303
- )
338
+ return this.createElement(this.c.li, node.data?.hProperties, this.mapMdastChildren(node))
304
339
  }
305
340
  case 'text': {
306
341
  if (!node.value) {
307
342
  return []
308
343
  }
309
344
  if (node.data?.hProperties) {
310
- return (
311
- <this.c.span {...node.data.hProperties}>
312
- {node.value}
313
- </this.c.span>
314
- )
345
+ return this.createElement(this.c.span, node.data.hProperties, node.value)
315
346
  }
316
347
  return node.value
317
348
  }
@@ -319,92 +350,70 @@ export class MdastToJsx {
319
350
  const src = node.url || ''
320
351
  const alt = node.alt || ''
321
352
  const title = node.title || ''
322
- return (
323
- <this.c.img
324
- src={src}
325
- alt={alt}
326
- title={title}
327
- {...node.data?.hProperties}
328
- />
329
- )
353
+ return this.createElement(this.c.img, {
354
+ src,
355
+ alt,
356
+ title,
357
+ ...node.data?.hProperties
358
+ })
330
359
  }
331
360
  case 'link': {
332
361
  const href = node.url || ''
333
362
  const title = node.title || ''
334
- return (
335
- <this.c.a {...{ href, title }} {...node.data?.hProperties}>
336
- {this.mapMdastChildren(node)}
337
- </this.c.a>
363
+ return this.createElement(
364
+ this.c.a,
365
+ { href, title, ...node.data?.hProperties },
366
+ this.mapMdastChildren(node)
338
367
  )
339
368
  }
340
369
  case 'strong': {
341
- return (
342
- <this.c.strong {...node.data?.hProperties}>
343
- {this.mapMdastChildren(node)}
344
- </this.c.strong>
345
- )
370
+ return this.createElement(this.c.strong, node.data?.hProperties, this.mapMdastChildren(node))
346
371
  }
347
372
  case 'emphasis': {
348
- return (
349
- <this.c.em {...node.data?.hProperties}>
350
- {this.mapMdastChildren(node)}
351
- </this.c.em>
352
- )
373
+ return this.createElement(this.c.em, node.data?.hProperties, this.mapMdastChildren(node))
353
374
  }
354
375
  case 'delete': {
355
- return (
356
- <this.c.del {...node.data?.hProperties}>
357
- {this.mapMdastChildren(node)}
358
- </this.c.del>
359
- )
376
+ return this.createElement(this.c.del, node.data?.hProperties, this.mapMdastChildren(node))
360
377
  }
361
378
  case 'inlineCode': {
362
379
  if (!node.value) {
363
380
  return []
364
381
  }
365
- return (
366
- <this.c.code {...node.data?.hProperties}>
367
- {node.value}
368
- </this.c.code>
369
- )
382
+ return this.createElement(this.c.code, node.data?.hProperties, node.value)
370
383
  }
371
384
  case 'break': {
372
- return <this.c.br {...node.data?.hProperties} />
385
+ return this.createElement(this.c.br, node.data?.hProperties)
373
386
  }
374
387
  case 'root': {
375
388
  if (node.data?.hProperties) {
376
- return (
377
- <this.c.div {...node.data.hProperties}>
378
- {this.mapMdastChildren(node)}
379
- </this.c.div>
380
- )
389
+ return this.createElement(this.c.div, node.data.hProperties, this.mapMdastChildren(node))
381
390
  }
382
- return <Fragment>{this.mapMdastChildren(node)}</Fragment>
391
+ return this.createElement(Fragment, null, this.mapMdastChildren(node))
383
392
  }
384
393
  case 'table': {
385
394
  const [head, ...body] = React.Children.toArray(
386
395
  this.mapMdastChildren(node),
387
396
  )
388
- return (
389
- <this.c.table {...node.data?.hProperties}>
390
- {head && <this.c.thead>{head}</this.c.thead>}
391
- {!!body?.length && <this.c.tbody>{body}</this.c.tbody>}
392
- </this.c.table>
397
+ return this.createElement(
398
+ this.c.table,
399
+ node.data?.hProperties,
400
+ head && this.createElement(this.c.thead, null, head),
401
+ !!body?.length && this.createElement(this.c.tbody, null, body)
393
402
  )
394
403
  }
395
404
  case 'tableRow': {
396
- return (
397
- <this.c.tr className='' {...node.data?.hProperties}>
398
- {this.mapMdastChildren(node)}
399
- </this.c.tr>
405
+ return this.createElement(
406
+ this.c.tr,
407
+ { className: '', ...node.data?.hProperties },
408
+ this.mapMdastChildren(node)
400
409
  )
401
410
  }
402
411
  case 'tableCell': {
403
412
  let content = this.mapMdastChildren(node)
404
- return (
405
- <this.c.td className='' {...node.data?.hProperties}>
406
- {content}
407
- </this.c.td>
413
+ return this.createElement(
414
+ this.c.td,
415
+ { className: '', ...node.data?.hProperties },
416
+ content
408
417
  )
409
418
  }
410
419
  case 'definition': {
@@ -421,10 +430,10 @@ export class MdastToJsx {
421
430
  }
422
431
  })
423
432
 
424
- return (
425
- <this.c.a href={href} {...node.data?.hProperties}>
426
- {this.mapMdastChildren(node)}
427
- </this.c.a>
433
+ return this.createElement(
434
+ this.c.a,
435
+ { href, ...node.data?.hProperties },
436
+ this.mapMdastChildren(node)
428
437
  )
429
438
  }
430
439
  case 'footnoteReference': {
@@ -442,14 +451,14 @@ export class MdastToJsx {
442
451
  return []
443
452
  }
444
453
 
445
- return (
446
- <Suspense fallback={null}>
447
- <HtmlToJsxConverter
448
- htmlText={text}
449
- instance={this}
450
- node={node}
451
- />
452
- </Suspense>
454
+ return this.createElement(
455
+ Suspense,
456
+ { fallback: null },
457
+ this.createElement(HtmlToJsxConverter, {
458
+ htmlText: text,
459
+ instance: this,
460
+ node
461
+ })
453
462
  )
454
463
  }
455
464
  case 'imageReference': {
@@ -473,15 +482,17 @@ export class MdastToJsx {
473
482
 
474
483
  export function getJsxAttrs(
475
484
  node: MdxJsxFlowElement | MdxJsxTextElement,
476
- onError: (err: { message: string }) => void = console.error,
485
+ onError: (err: SafeMdxError) => void = console.error,
477
486
  ) {
478
487
  let attrsList = node.attributes
479
488
  .map((attr) => {
480
489
  if (attr.type === 'mdxJsxExpressionAttribute') {
490
+
481
491
  onError({
482
492
  message: `Expressions in jsx props are not supported (${attr.value
483
493
  .replace(/\n+/g, ' ')
484
494
  .replace(/ +/g, ' ')})`,
495
+ line: attr.position?.start?.line,
485
496
  })
486
497
  return
487
498
  }
@@ -531,6 +542,7 @@ export function getJsxAttrs(
531
542
 
532
543
  onError({
533
544
  message: `Expressions in jsx props are not supported (${attr.name}={${v.value}})`,
545
+ line: attr.position?.start?.line,
534
546
  })
535
547
  } else {
536
548
  console.log('unhandled attr', { attr }, attr.type)