Skip to content

Commit 76cbff2

Browse files
committed
feat: support JSX component imports in nested editors
1 parent 82c360e commit 76cbff2

File tree

6 files changed

+223
-24
lines changed

6 files changed

+223
-24
lines changed

src/examples/jsx.tsx

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { toolbarPlugin } from '../plugins/toolbar'
77
import { Button } from '../plugins/toolbar/primitives/toolbar'
88
import { NestedLexicalEditor } from '../plugins/core/NestedLexicalEditor'
99
import { MdxJsxTextElement } from 'mdast-util-mdx'
10-
import { headingsPlugin } from '..'
10+
import { AdmonitionDirectiveDescriptor, directivesPlugin, headingsPlugin } from '..'
1111
import { usePublisher } from '@mdxeditor/gurx'
1212

1313
const jsxComponentDescriptors: JsxComponentDescriptor[] = [
@@ -213,3 +213,121 @@ export const ExpressionAttributes = () => {
213213
</div>
214214
)
215215
}
216+
217+
const CatchAllDescriptor: JsxComponentDescriptor[] = [
218+
{
219+
name: '*',
220+
kind: 'flow',
221+
props: [],
222+
Editor: GenericJsxEditor
223+
}
224+
]
225+
226+
export const ImportStatements = () => {
227+
const rawMd = React.useRef(`
228+
import { Foo } from '@bar/foo';
229+
import Bar from '@foo/bar';
230+
231+
Hello
232+
233+
<Foo />
234+
<Bar />
235+
`)
236+
const [md, setMd] = React.useState('')
237+
return (
238+
<div>
239+
<h3>Original Source</h3>
240+
<pre>
241+
<code>{rawMd.current}</code>
242+
</pre>
243+
<h3>MDXEditor</h3>
244+
<MDXEditor
245+
onChange={(e) => {
246+
setMd(e)
247+
}}
248+
markdown={rawMd.current}
249+
plugins={[jsxPlugin({ jsxComponentDescriptors: CatchAllDescriptor })]}
250+
/>
251+
<h3>Serialized MDX Editor</h3>
252+
<pre>
253+
<code>{md}</code>
254+
</pre>
255+
</div>
256+
)
257+
}
258+
export const ImportStatementsNested = () => {
259+
const rawMd = React.useRef(`
260+
Hello
261+
262+
import Foo from '@bar';
263+
264+
<Foo />
265+
266+
:::info
267+
import Buzz from '@buzz';
268+
269+
Hello from <Buzz />
270+
:::
271+
272+
`)
273+
const [md, setMd] = React.useState('')
274+
return (
275+
<div>
276+
<h3>Original Source</h3>
277+
<pre>
278+
<code>{rawMd.current}</code>
279+
</pre>
280+
<h3>MDXEditor</h3>
281+
<MDXEditor
282+
onChange={(e) => {
283+
setMd(e)
284+
}}
285+
markdown={rawMd.current}
286+
plugins={[
287+
directivesPlugin({ directiveDescriptors: [AdmonitionDirectiveDescriptor] }),
288+
headingsPlugin(),
289+
jsxPlugin({
290+
jsxComponentDescriptors: [
291+
{
292+
name: 'Zazz',
293+
kind: 'flow',
294+
source: '@zazz',
295+
defaultExport: true,
296+
props: [],
297+
hasChildren: true,
298+
Editor: GenericJsxEditor
299+
},
300+
...CatchAllDescriptor
301+
]
302+
}),
303+
toolbarPlugin({
304+
toolbarContents: () => {
305+
// eslint-disable-next-line react-hooks/rules-of-hooks
306+
const insertJsx = usePublisher(insertJsx$)
307+
return (
308+
<>
309+
<Button
310+
onClick={() => {
311+
insertJsx({
312+
name: 'Zazz',
313+
kind: 'flow',
314+
props: {},
315+
children: [{ type: 'paragraph', children: [{ type: 'text', value: 'Hello from Zazz' }] }]
316+
})
317+
}}
318+
>
319+
Zazz
320+
</Button>
321+
</>
322+
)
323+
}
324+
})
325+
]}
326+
/>
327+
<h3>Serialized MDX Editor</h3>
328+
<pre>
329+
<code>{md}</code>
330+
</pre>
331+
</div>
332+
)
333+
}

src/exportMarkdownFromLexical.ts

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Options as ToMarkdownOptions, toMarkdown } from 'mdast-util-to-markdown
55
import type { JsxComponentDescriptor } from './plugins/jsx'
66
import { isMdastHTMLNode } from './plugins/core/MdastHTMLNode'
77
import { mergeStyleAttributes } from './utils/mergeStyleAttributes'
8+
import { ImportStatement } from './importMarkdownToLexical'
89

910
export type { Options as ToMarkdownOptions } from 'mdast-util-to-markdown'
1011

@@ -58,7 +59,7 @@ export interface LexicalExportVisitor<LN extends LexicalNode, UN extends Mdast.N
5859
* @param componentName - the name of the component that has to be imported.
5960
* @see {@link JsxComponentDescriptor}
6061
*/
61-
registerReferredComponent(componentName: string): void
62+
registerReferredComponent(componentName: string, importStatement?: ImportStatement): void
6263
/**
6364
* visits the specified lexical node
6465
*/
@@ -117,13 +118,17 @@ export function exportLexicalTreeToMdast({
117118
}: ExportLexicalTreeOptions): Mdast.Root {
118119
let unistRoot: Mdast.Root | null = null
119120
const referredComponents = new Set<string>()
121+
const knownImportSources = new Map<string, ImportStatement>()
120122

121123
visitors = visitors.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
122124

123125
visit(root, null)
124126

125-
function registerReferredComponent(componentName: string) {
127+
function registerReferredComponent(componentName: string, importStatement?: ImportStatement) {
126128
referredComponents.add(componentName)
129+
if (importStatement) {
130+
knownImportSources.set(componentName, { ...importStatement })
131+
}
127132
}
128133

129134
function appendToParent<T extends Mdast.Parent, C extends Mdast.RootContent>(parentNode: T, node: C): C | Mdast.Root {
@@ -165,7 +170,6 @@ export function exportLexicalTreeToMdast({
165170
cause: lexicalNode
166171
})
167172
}
168-
169173
visitor.visitLexicalNode?.({
170174
lexicalNode,
171175
mdastParent: mdastParent!,
@@ -197,10 +201,10 @@ export function exportLexicalTreeToMdast({
197201
// iterate over all referred components and construct import statements, then append them to the root
198202
const importsMap = new Map<string, string[]>()
199203
const defaultImportsMap = new Map<string, string>()
200-
201204
for (const componentName of referredComponents) {
202205
const descriptor =
203206
jsxComponentDescriptors.find((descriptor) => descriptor.name === componentName) ??
207+
knownImportSources.get(componentName) ??
204208
jsxComponentDescriptors.find((descriptor) => descriptor.name === '*')
205209
if (!descriptor) {
206210
throw new Error(`Component ${componentName} is used but not imported`)
@@ -221,6 +225,27 @@ export function exportLexicalTreeToMdast({
221225
}
222226
}
223227

228+
/**
229+
* even when the import statements should not be added,
230+
* raw import statements should not be removed.
231+
*/
232+
if (!addImportStatements) {
233+
// filter out new imports
234+
for (const [path, names] of importsMap.entries()) {
235+
const cleaned = names.filter((n) => knownImportSources.has(n))
236+
if (cleaned.length > 0) {
237+
importsMap.set(path, cleaned)
238+
} else {
239+
importsMap.delete(path)
240+
}
241+
}
242+
for (const key of defaultImportsMap.keys()) {
243+
if (!knownImportSources.has(key)) {
244+
defaultImportsMap.delete(key)
245+
}
246+
}
247+
}
248+
224249
const imports = Array.from(importsMap).map(([source, componentNames]) => {
225250
return {
226251
type: 'mdxjsEsm',
@@ -240,13 +265,10 @@ export function exportLexicalTreeToMdast({
240265
const typedRoot = unistRoot as Mdast.Root
241266

242267
const frontmatter = typedRoot.children.find((child) => child.type === 'yaml')
243-
244-
if (addImportStatements) {
245-
if (frontmatter) {
246-
typedRoot.children.splice(typedRoot.children.indexOf(frontmatter) + 1, 0, ...imports)
247-
} else {
248-
typedRoot.children.unshift(...imports)
249-
}
268+
if (frontmatter) {
269+
typedRoot.children.splice(typedRoot.children.indexOf(frontmatter) + 1, 0, ...imports)
270+
} else {
271+
typedRoot.children.unshift(...imports)
250272
}
251273

252274
fixWrappingWhitespace(typedRoot, [])

src/importMarkdownToLexical.ts

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,25 @@
22
import { ElementNode, LexicalNode } from 'lexical'
33
import * as Mdast from 'mdast'
44
import { fromMarkdown, type Options } from 'mdast-util-from-markdown'
5+
import { MdxjsEsm } from 'mdast-util-mdx'
56
import { toMarkdown } from 'mdast-util-to-markdown'
67
import { ParseOptions } from 'micromark-util-types'
78
import { FORMAT } from './FormatConstants'
8-
import { JsxComponentDescriptor } from './plugins/jsx'
9-
import { DirectiveDescriptor } from './plugins/directives'
109
import { CodeBlockEditorDescriptor } from './plugins/codeblock'
10+
import { DirectiveDescriptor } from './plugins/directives'
11+
import { JsxComponentDescriptor } from './plugins/jsx'
12+
13+
export interface ImportStatement {
14+
source: string
15+
defaultExport: boolean
16+
}
17+
18+
/**
19+
* Metadata that is provided to the visitors
20+
*/
21+
interface MetaData {
22+
importDeclarations: Record<string, ImportStatement>
23+
}
1124

1225
/**
1326
* The registered descriptors for composite nodes (jsx, directives, code blocks).
@@ -50,6 +63,10 @@ export interface MdastImportVisitor<UN extends Mdast.Nodes> {
5063
* The descriptors for composite nodes (jsx, directives, code blocks).
5164
*/
5265
descriptors: Descriptors
66+
/**
67+
* metaData: context data provided from the import visitor.
68+
*/
69+
metaData: MetaData
5370
/**
5471
* A set of convenience utilities that can be used to add nodes to the lexical tree.
5572
*/
@@ -147,7 +164,7 @@ export class MarkdownParseError extends Error {
147164
}
148165

149166
/**
150-
* An error that gets thrown when the Markdown parsing encounters an node that has no corresponding {@link MdastImportVisitor}.
167+
* An error that gets thrown when the Markdown parsing encounters a node that has no corresponding {@link MdastImportVisitor}.
151168
* @group Markdown Processing
152169
*/
153170
export class UnrecognizedMarkdownConstructError extends Error {
@@ -157,6 +174,34 @@ export class UnrecognizedMarkdownConstructError extends Error {
157174
}
158175
}
159176

177+
function gatherMetadata(mdastNode: Mdast.RootContent | Mdast.Root): MetaData {
178+
const importsMap = new Map<string, ImportStatement>()
179+
if (mdastNode.type !== 'root') {
180+
return {
181+
importDeclarations: {}
182+
}
183+
}
184+
const importStatements = mdastNode.children
185+
.filter((n) => n.type === 'mdxjsEsm')
186+
.filter((n) => (n as MdxjsEsm).value.startsWith('import ')) as MdxjsEsm[]
187+
importStatements.forEach((imp) => {
188+
;(imp.data?.estree?.body ?? []).forEach((declaration) => {
189+
if (declaration.type !== 'ImportDeclaration') {
190+
return
191+
}
192+
declaration.specifiers.forEach((specifier) => {
193+
importsMap.set(specifier.local.name, {
194+
source: `${declaration.source.value}`,
195+
defaultExport: specifier.type === 'ImportDefaultSpecifier'
196+
})
197+
})
198+
})
199+
})
200+
return {
201+
importDeclarations: Object.fromEntries(importsMap.entries())
202+
}
203+
}
204+
160205
/** @internal */
161206
export function importMarkdownToLexical({
162207
root,
@@ -196,6 +241,7 @@ export function importMarkdownToLexical({
196241
export function importMdastTreeToLexical({ root, mdastRoot, visitors, ...descriptors }: MdastTreeImportOptions): void {
197242
const formattingMap = new WeakMap<Mdast.Parent, number>()
198243
const styleMap = new WeakMap<Mdast.Parent, string>()
244+
const metaData: MetaData = gatherMetadata(mdastRoot)
199245

200246
visitors = visitors.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
201247

@@ -234,6 +280,7 @@ export function importMdastTreeToLexical({ root, mdastRoot, visitors, ...descrip
234280
lexicalParent,
235281
mdastParent,
236282
descriptors,
283+
metaData,
237284
actions: {
238285
visitChildren,
239286
addAndStepInto(lexicalNode) {

0 commit comments

Comments
 (0)