safe-mdx 1.3.9 → 1.4.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.
Files changed (43) hide show
  1. package/README.md +4 -0
  2. package/dist/dynamic-esm-component.js.map +1 -1
  3. package/dist/esm-parser.js.map +1 -1
  4. package/dist/esm-parser.test.js +176 -1
  5. package/dist/esm-parser.test.js.map +1 -1
  6. package/dist/html/convert-attributes.js.map +1 -1
  7. package/dist/html/html-and-md.test.js.map +1 -1
  8. package/dist/html/html-to-mdx-ast.js.map +1 -1
  9. package/dist/html/html-to-mdx-ast.test.js +3 -3
  10. package/dist/html/html-to-mdx-ast.test.js.map +1 -1
  11. package/dist/html/remark-mdx-jsx-normalize.d.ts.map +1 -1
  12. package/dist/html/remark-mdx-jsx-normalize.js.map +1 -1
  13. package/dist/html/valid-html-elements.d.ts.map +1 -1
  14. package/dist/html/valid-html-elements.js +1 -1
  15. package/dist/html/valid-html-elements.js.map +1 -1
  16. package/dist/parse.d.ts +44 -0
  17. package/dist/parse.d.ts.map +1 -1
  18. package/dist/parse.js +127 -0
  19. package/dist/parse.js.map +1 -1
  20. package/dist/safe-mdx.d.ts +20 -2
  21. package/dist/safe-mdx.d.ts.map +1 -1
  22. package/dist/safe-mdx.js +55 -4
  23. package/dist/safe-mdx.js.map +1 -1
  24. package/dist/safe-mdx.test.js +110 -5
  25. package/dist/safe-mdx.test.js.map +1 -1
  26. package/dist/streaming.js.map +1 -1
  27. package/package.json +11 -6
  28. package/src/esm-parser.test.ts +189 -1
  29. package/src/parse.ts +165 -0
  30. package/src/safe-mdx.test.tsx +119 -6
  31. package/src/safe-mdx.tsx +67 -4
  32. package/dist/assets/HtmlToJsxConverter-Ds0bTjpw.js +0 -24
  33. package/dist/assets/_commonjsHelpers-CqkleIqs.js +0 -1
  34. package/dist/assets/index-B5fPOjPt.css +0 -1
  35. package/dist/assets/index-B7ATSoRE.js +0 -9
  36. package/dist/assets/index-BwZ2FTRd.js +0 -146
  37. package/dist/assets/index-R1UqLMGJ.js +0 -1
  38. package/dist/assets/index-c0qeY2gs.js +0 -9
  39. package/dist/assets/jsx-runtime-BhZZLbvw.js +0 -9
  40. package/dist/assets/jsx-runtime-NArryeSM.js +0 -1
  41. package/dist/assets/react-Ca6JzGpx.js +0 -1
  42. package/dist/assets/react-dom-BYRHYqYl.js +0 -1
  43. package/dist/index.html +0 -19
@@ -2529,17 +2529,17 @@ test('component props schema validation with zod', () => {
2529
2529
  "errors": [
2530
2530
  {
2531
2531
  "line": 5,
2532
- "message": "Invalid props for component "Heading" at "level": Number must be less than or equal to 6",
2532
+ "message": "Invalid props for component "Heading" at "level": Too big: expected number to be <=6",
2533
2533
  "schemaPath": "level",
2534
2534
  },
2535
2535
  {
2536
2536
  "line": 7,
2537
- "message": "Invalid props for component "Cards" at "count": Number must be greater than 0",
2537
+ "message": "Invalid props for component "Cards" at "count": Too small: expected number to be >0",
2538
2538
  "schemaPath": "count",
2539
2539
  },
2540
2540
  {
2541
2541
  "line": 9,
2542
- "message": "Invalid props for component "Cards" at "count": Expected number, received string",
2542
+ "message": "Invalid props for component "Cards" at "count": Invalid input: expected number, received string",
2543
2543
  "schemaPath": "count",
2544
2544
  },
2545
2545
  ],
@@ -2748,12 +2748,12 @@ test('validation error includes schema path', () => {
2748
2748
  "errors": [
2749
2749
  {
2750
2750
  "line": 1,
2751
- "message": "Invalid props for component "Heading" at "user.age": Number must be greater than or equal to 0",
2751
+ "message": "Invalid props for component "Heading" at "user.age": Too small: expected number to be >=0",
2752
2752
  "schemaPath": "user.age",
2753
2753
  },
2754
2754
  {
2755
2755
  "line": 1,
2756
- "message": "Invalid props for component "Heading" at "settings.theme": Invalid enum value. Expected 'light' | 'dark', received 'invalid'",
2756
+ "message": "Invalid props for component "Heading" at "settings.theme": Invalid option: expected one of "light"|"dark"",
2757
2757
  "schemaPath": "settings.theme",
2758
2758
  },
2759
2759
  ],
@@ -3498,5 +3498,118 @@ test("skip unknown elements in complex nested HTML structures", () => {
3498
3498
  </div>
3499
3499
  `);
3500
3500
 
3501
- expect(html).toMatchInlineSnapshot(`"<h1>Main Title</h1><article><header><h1>Article Title</h1></header><section><blockquote><p>A famous quote</p></blockquote></section><footer></footer></article><h2>Another Section</h2><div></div>"`);
3501
+ expect(html).toMatchInlineSnapshot(`"<h1>Main Title</h1><article><header><h1>Article Title</h1></header><section><blockquote><p>A famous quote</p></blockquote></section><footer></footer></article><h2>Another Section</h2><div></div>"`);
3502
3502
  });
3503
+
3504
+ /* ── modules prop tests ─────────────────────────────────────────────── */
3505
+
3506
+ test('modules prop: resolves named import from absolute path', () => {
3507
+ function CustomBadge({ label }: { label: string }) {
3508
+ return <span className="badge">{label}</span>
3509
+ }
3510
+
3511
+ const code = dedent`
3512
+ import { CustomBadge } from '/snippets/badge'
3513
+
3514
+ # Hello
3515
+
3516
+ <CustomBadge label="Works" />
3517
+ `
3518
+ const mdast = mdxParse(code)
3519
+ const visitor = new MdastToJsx({
3520
+ markdown: code,
3521
+ mdast,
3522
+ components,
3523
+ modules: {
3524
+ './snippets/badge.tsx': { CustomBadge },
3525
+ },
3526
+ baseUrl: './pages/',
3527
+ })
3528
+ const result = visitor.run()
3529
+ const html = renderToStaticMarkup(result)
3530
+ expect(html).toMatchInlineSnapshot(`"<h1>Hello</h1><span class="badge">Works</span>"`)
3531
+ expect(visitor.errors).toMatchInlineSnapshot(`[]`)
3532
+ })
3533
+
3534
+ test('modules prop: resolves default import from relative path', () => {
3535
+ function MyCard({ children }: { children?: React.ReactNode }) {
3536
+ return <div className="card">{children}</div>
3537
+ }
3538
+
3539
+ const code = dedent`
3540
+ import MyCard from './card'
3541
+
3542
+ <MyCard>content</MyCard>
3543
+ `
3544
+ const mdast = mdxParse(code)
3545
+ const visitor = new MdastToJsx({
3546
+ markdown: code,
3547
+ mdast,
3548
+ components,
3549
+ modules: {
3550
+ './pages/card.tsx': { default: MyCard },
3551
+ },
3552
+ baseUrl: './pages/',
3553
+ })
3554
+ const result = visitor.run()
3555
+ const html = renderToStaticMarkup(result)
3556
+ expect(html).toMatchInlineSnapshot(`"<div class="card">content</div>"`)
3557
+ expect(visitor.errors).toMatchInlineSnapshot(`[]`)
3558
+ })
3559
+
3560
+ test('modules prop: resolves namespace import with dot notation', () => {
3561
+ function Card({ title }: { title: string }) {
3562
+ return <div>{title}</div>
3563
+ }
3564
+ function Badge({ label }: { label: string }) {
3565
+ return <span>{label}</span>
3566
+ }
3567
+
3568
+ const code = dedent`
3569
+ import * as UI from './ui'
3570
+
3571
+ <UI.Card title="hello" />
3572
+ <UI.Badge label="tag" />
3573
+ `
3574
+ const mdast = mdxParse(code)
3575
+ const visitor = new MdastToJsx({
3576
+ markdown: code,
3577
+ mdast,
3578
+ components,
3579
+ modules: {
3580
+ './components/ui.tsx': { Card, Badge },
3581
+ },
3582
+ baseUrl: './components/',
3583
+ })
3584
+ const result = visitor.run()
3585
+ const html = renderToStaticMarkup(result)
3586
+ expect(html).toMatchInlineSnapshot(`"<div>hello</div><span>tag</span>"`)
3587
+ expect(visitor.errors).toMatchInlineSnapshot(`[]`)
3588
+ })
3589
+
3590
+ test('modules prop: unresolved import produces error', () => {
3591
+ const code = dedent`
3592
+ import { Missing } from './nonexistent'
3593
+
3594
+ <Missing />
3595
+ `
3596
+ const mdast = mdxParse(code)
3597
+ const visitor = new MdastToJsx({
3598
+ markdown: code,
3599
+ mdast,
3600
+ components,
3601
+ modules: {},
3602
+ baseUrl: './pages/',
3603
+ })
3604
+ const result = visitor.run()
3605
+ const html = renderToStaticMarkup(result)
3606
+ expect(html).toMatchInlineSnapshot(`""`)
3607
+ expect(visitor.errors).toMatchInlineSnapshot(`
3608
+ [
3609
+ {
3610
+ "line": 3,
3611
+ "message": "Unsupported jsx component Missing",
3612
+ },
3613
+ ]
3614
+ `)
3615
+ })
package/src/safe-mdx.tsx CHANGED
@@ -7,8 +7,9 @@ import type { Node, Parent, Root, RootContent } from 'mdast'
7
7
  import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx-jsx'
8
8
 
9
9
  import { Fragment, ReactNode } from 'react'
10
- import { DynamicEsmComponent } from './dynamic-esm-component.js'
10
+ import { DynamicEsmComponent } from 'safe-mdx/client'
11
11
  import { extractComponentInfo, parseEsmImports } from './esm-parser.js'
12
+ import { resolveModulePath, type EagerModules } from './parse.js'
12
13
  import { htmlToMdxAst } from './html/html-to-mdx-ast.js'
13
14
  import { validHtmlElements, nativeTags } from './html/valid-html-elements.js'
14
15
 
@@ -51,15 +52,24 @@ export const SafeMdxRenderer = React.memo(function SafeMdxRenderer({
51
52
  createElement,
52
53
  allowClientEsmImports = false,
53
54
  addMarkdownLineNumbers = false,
55
+ modules,
56
+ baseUrl,
54
57
  }: {
55
58
  components?: ComponentsMap
56
59
  markdown?: string
57
- mdast: MyRootContent
60
+ mdast?: MyRootContent
58
61
  renderNode?: RenderNode
59
62
  componentPropsSchema?: ComponentPropsSchema
60
63
  createElement?: CreateElementFunction
61
64
  allowClientEsmImports?: boolean
62
65
  addMarkdownLineNumbers?: boolean
66
+ /** Pre-resolved modules keyed by file path (e.g. from `import.meta.glob`).
67
+ * When MDX contains `import { Card } from './card'`, the import source is
68
+ * resolved against these keys using `baseUrl` for relative paths. */
69
+ modules?: EagerModules
70
+ /** Directory of the current MDX file, used to resolve relative import
71
+ * sources against `modules` keys. E.g. `'./pages/getting-started/'` */
72
+ baseUrl?: string
63
73
  }) {
64
74
  const visitor = new MdastToJsx({
65
75
  markdown,
@@ -70,6 +80,8 @@ export const SafeMdxRenderer = React.memo(function SafeMdxRenderer({
70
80
  createElement,
71
81
  allowClientEsmImports,
72
82
  addMarkdownLineNumbers,
83
+ modules,
84
+ baseUrl,
73
85
  })
74
86
  const result = visitor.run()
75
87
  return result
@@ -87,6 +99,8 @@ export class MdastToJsx {
87
99
  esmImports: Map<string, string> = new Map()
88
100
  allowClientEsmImports: boolean
89
101
  addMarkdownLineNumbers: boolean
102
+ modules?: EagerModules
103
+ baseUrl?: string
90
104
 
91
105
  constructor({
92
106
  markdown: code = '',
@@ -97,6 +111,8 @@ export class MdastToJsx {
97
111
  createElement = React.createElement,
98
112
  allowClientEsmImports = false,
99
113
  addMarkdownLineNumbers = false,
114
+ modules,
115
+ baseUrl,
100
116
  }: {
101
117
  markdown?: string
102
118
  mdast: MyRootContent
@@ -109,6 +125,8 @@ export class MdastToJsx {
109
125
  createElement?: CreateElementFunction
110
126
  allowClientEsmImports?: boolean
111
127
  addMarkdownLineNumbers?: boolean
128
+ modules?: EagerModules
129
+ baseUrl?: string
112
130
  }) {
113
131
  this.str = code
114
132
 
@@ -124,6 +142,9 @@ export class MdastToJsx {
124
142
 
125
143
  this.addMarkdownLineNumbers = addMarkdownLineNumbers
126
144
 
145
+ this.modules = modules
146
+ this.baseUrl = baseUrl
147
+
127
148
  this.c = {
128
149
  ...Object.fromEntries(
129
150
  nativeTags.map((tag) => {
@@ -132,6 +153,45 @@ export class MdastToJsx {
132
153
  ),
133
154
  ...components,
134
155
  }
156
+
157
+ }
158
+
159
+ /**
160
+ * Resolve import declarations from an mdxjsEsm node against `this.modules`.
161
+ * Resolved components are added directly to `this.c` (the component map)
162
+ * so the existing `accessWithDot` lookup finds them.
163
+ */
164
+ resolveImportsFromModules(node: MyRootContent): void {
165
+ const estree = (node as any).data?.estree
166
+ if (!estree) return
167
+
168
+ const moduleKeys = Object.keys(this.modules!)
169
+
170
+ for (const statement of estree.body) {
171
+ if (statement.type !== 'ImportDeclaration') continue
172
+ const source: string = statement.source?.value
173
+ if (typeof source !== 'string') continue
174
+
175
+ const resolved = resolveModulePath(source, this.baseUrl || './', moduleKeys)
176
+ if (!resolved) continue
177
+ const mod = this.modules![resolved]
178
+ if (!mod) continue
179
+
180
+ for (const spec of statement.specifiers ?? []) {
181
+ if (spec.type === 'ImportDefaultSpecifier') {
182
+ this.c[spec.local.name] = mod.default ?? mod
183
+ } else if (spec.type === 'ImportSpecifier') {
184
+ const importedName = spec.imported.type === 'Identifier'
185
+ ? spec.imported.name
186
+ : String(spec.imported.value)
187
+ this.c[spec.local.name] = mod[importedName]
188
+ } else if (spec.type === 'ImportNamespaceSpecifier') {
189
+ // Namespace import: import * as UI from '...'
190
+ // Supports <UI.Card> via accessWithDot
191
+ this.c[spec.local.name] = mod
192
+ }
193
+ }
194
+ }
135
195
  }
136
196
 
137
197
  addLineNumberToProps(
@@ -240,7 +300,6 @@ export class MdastToJsx {
240
300
  // Handle ESM imported component
241
301
  const { importUrl, componentName } =
242
302
  extractComponentInfo(esmImportInfo)
243
-
244
303
  Component = DynamicEsmComponent
245
304
  let attrsList = this.getJsxAttrs(node, (err) => {
246
305
  this.errors.push(err)
@@ -589,7 +648,11 @@ export class MdastToJsx {
589
648
 
590
649
  switch (node.type) {
591
650
  case 'mdxjsEsm': {
592
- // Parse ESM imports and merge into our imports map (only if allowed)
651
+ // Resolve imports from pre-loaded modules (server-side)
652
+ if (this.modules) {
653
+ this.resolveImportsFromModules(node)
654
+ }
655
+ // Parse ESM imports for client-side dynamic loading (only if allowed)
593
656
  if (this.allowClientEsmImports) {
594
657
  const parsedImports = parseEsmImports(node, (err) =>
595
658
  this.errors.push(err),