Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ To handle common basic HTML formatting (e.g. `u` tags), the default parsing incl

Another problem that can occur during markdown parsing is the lack of plugins to handle certain markdown features. For example, the markdown may include table syntax, but the editor may not have the table plugin enabled. Internally, this exception is going to happen at the phase where MDAST nodes are converted into lexical nodes (the UI rendered in the rich text editing surface). Just like in the previous case, you can use the `onError` prop to handle these errors. You can also add a custom "catch-all" plugin that registers an MDAST visitor with a low priority that will handle all unknown nodes. See `./extending-the-editor` for more information.

## Errors due to JSX kind mismatches

The MDX parser can produce a text JSX node for a component declared as `flow`, or a flow JSX node for a component declared as `text`. The `jsxPlugin` `kindMismatchPolicy` parameter controls this behavior. Its default `source` value preserves the parser's content model. Use `normalize` to make descriptors authoritative for lossless conversions, or `error` to reject all mismatches.

Normalization reports an error when it would need to split surrounding paragraph text or flatten multiple blocks. The `onError` payload identifies the component, parsed kind, declared kind, and active policy. The original Markdown remains available so that the author can recover in source mode.

## Enable source mode to allow the user to recover from errors

The diff-source plugin can be used as an "escape hatch" for potentially invalid markdown. Out of the box, the plugin will attach listeners to the markdown conversion, and, if it fails, will display an error message suggesting the user to switch to source mode and fix the problem there. If the user fixes the problem, then switching to rich text mode will work and the content will be displayed correctly.
27 changes: 27 additions & 0 deletions docs/jsx.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,33 @@ more Content
</BlockNode>
```

## JSX kind mismatches

The MDX parser determines whether JSX is a text or flow node from its Markdown context. A component descriptor declares how MDXEditor intends to edit that component. These values can differ. For example, this valid one-line MDX is parsed as a text node even when `Card.Header` has a `flow` descriptor:

```text
<Card.Header><Icon /> Text</Card.Header>
```

Use `kindMismatchPolicy` to select how `jsxPlugin` handles the mismatch:

```tsx
jsxPlugin({
jsxComponentDescriptors,
kindMismatchPolicy: 'normalize'
})
```

- `source` is the default. The parsed node type controls the nested editor, and MDXEditor does not rewrite the JSX kind.
- `normalize` makes the descriptor authoritative when conversion is lossless. A standalone one-line text element declared as `flow` is converted to flow content and can be serialized in multiline form.
- `error` rejects every mismatch through the editor's `onError` callback.

The policy applies only to nodes handled as descriptor-based JSX components. Built-in HTML elements keep their existing handling, including when the descriptor list contains a wildcard entry.

Normalization does not split surrounding paragraphs or flatten multiple blocks. Such conversions could change or discard content, so they produce a recoverable error. Write block components on separate lines when they occur next to other text.

`GenericJsxEditor` follows these policies. A custom JSX editor receives both `mdastNode` and `descriptor`; in `source` mode, it should derive its content model from `mdastNode.type`.

## Types of properties

There are two types of properties - "textual" and "expressions" in JSX. You can define type in `JsxComponentDescriptor`. `jsxPlugin` will treat the value based on this setting. For example, this code:
Expand Down
38 changes: 27 additions & 11 deletions src/importMarkdownToLexical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { FORMAT } from './FormatConstants'
import { CodeBlockEditorDescriptor } from './plugins/codeblock'
import { DirectiveDescriptor } from './plugins/directives'
import { JsxComponentDescriptor } from './plugins/jsx'
import { type JsxKindMismatchPolicy, reconcileJsxKindMismatches } from './plugins/jsx/reconcileJsxKind'

export interface ImportStatement {
source: string
Expand All @@ -27,6 +28,8 @@ interface MetaData {
*/
export interface Descriptors {
jsxComponentDescriptors: JsxComponentDescriptor[]
/** How parsed JSX node kinds are reconciled with their component descriptors. */
jsxKindMismatchPolicy?: JsxKindMismatchPolicy
directiveDescriptors: DirectiveDescriptor[]
codeBlockEditorDescriptors: CodeBlockEditorDescriptor[]
/**
Expand Down Expand Up @@ -122,6 +125,8 @@ export interface MdastImportVisitor<UN extends Mdast.Nodes> {
* Default 0, optional, sets the priority of the visitor. The higher the number, the earlier it will be called.
*/
priority?: number
/** @internal Marks the visitor that owns descriptor-based JSX kind reconciliation. */
jsxKindReconciliationOwner?: boolean
}

function isParent(node: unknown): node is Mdast.Parent {
Expand Down Expand Up @@ -242,12 +247,31 @@ export function importMarkdownToLexical({
}

export function importMdastTreeToLexical({ root, mdastRoot, visitors, ...descriptors }: MdastTreeImportOptions): void {
visitors = visitors.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))

function findVisitor(mdastNode: Mdast.Nodes, skipVisitors: Set<number> | null = null) {
return visitors.find((visitor, index) => {
if (skipVisitors?.has(index)) {
return false
}
if (typeof visitor.testNode === 'string') {
return visitor.testNode === mdastNode.type
}
return visitor.testNode(mdastNode, descriptors)
})
}

mdastRoot = reconcileJsxKindMismatches(
mdastRoot,
descriptors.jsxComponentDescriptors,
descriptors.jsxKindMismatchPolicy ?? 'source',
(node) => findVisitor(node)?.jsxKindReconciliationOwner === true
)

const formattingMap = new WeakMap<Mdast.Parent, number>()
const styleMap = new WeakMap<Mdast.Parent, string>()
const metaData: MetaData = gatherMetadata(mdastRoot)

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

function visitChildren(mdastNode: Mdast.Parent, lexicalParent: LexicalNode) {
if (!isParent(mdastNode)) {
throw new Error('Attempting to visit children of a non-parent')
Expand All @@ -263,15 +287,7 @@ export function importMdastTreeToLexical({ root, mdastRoot, visitors, ...descrip
mdastParent: Mdast.Parent | null,
skipVisitors: Set<number> | null = null
) {
const visitor = visitors.find((visitor, index) => {
if (skipVisitors?.has(index)) {
return false
}
if (typeof visitor.testNode === 'string') {
return visitor.testNode === mdastNode.type
}
return visitor.testNode(mdastNode, descriptors)
})
const visitor = findVisitor(mdastNode, skipVisitors)
if (!visitor) {
try {
throw new UnrecognizedMarkdownConstructError(`Unsupported markdown syntax: ${toMarkdown(mdastNode)}`)
Expand Down
2 changes: 1 addition & 1 deletion src/jsx-editors/GenericJsxEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export const GenericJsxEditor: React.FC<GenericJsxEditorProps> = ({ mdastNode, d

{descriptor.hasChildren ? (
<NestedLexicalEditor<MdxJsxTextElement | MdxJsxFlowElement>
block={descriptor.kind === 'flow'}
block={mdastNode.type === 'mdxJsxFlowElement'}
getContent={(node) => node.children as PhrasingContent[]}
getUpdatedMdastNode={(mdastNode, children) => {
return { ...mdastNode, children } as any
Expand Down
10 changes: 9 additions & 1 deletion src/plugins/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
UnrecognizedMarkdownConstructError,
importMarkdownToLexical
} from '../../importMarkdownToLexical'
import { JsxKindMismatchError, type JsxKindMismatchPolicy } from '../jsx/reconcileJsxKind'
import { noop } from '../../utils/fp'
import type { JsxComponentDescriptor } from '../jsx'
import { GenericHTMLNode } from './GenericHTMLNode'
Expand Down Expand Up @@ -287,6 +288,12 @@ export const jsxIsAvailable$ = Cell(false)
*/
export const jsxComponentDescriptors$ = Cell<JsxComponentDescriptor[]>([])

/**
* Controls how parsed JSX node kinds are reconciled with component descriptors.
* @group JSX
*/
export const jsxKindMismatchPolicy$ = Cell<JsxKindMismatchPolicy>('source')

/**
* Contains the currently registered Markdown directive descriptors.
* @group Directive
Expand Down Expand Up @@ -685,13 +692,14 @@ function tryImportingMarkdown(r: Realm, node: ImportPoint, markdownValue: string
markdown: markdownValue,
syntaxExtensions: r.getValue(syntaxExtensions$),
jsxComponentDescriptors: r.getValue(jsxComponentDescriptors$),
jsxKindMismatchPolicy: r.getValue(jsxKindMismatchPolicy$),
directiveDescriptors: r.getValue(directiveDescriptors$),
codeBlockEditorDescriptors: r.getValue(codeBlockEditorDescriptors$),
defaultCodeBlockLanguage: r.getValue(defaultCodeBlockLanguage$)
})
r.pub(markdownProcessingError$, null)
} catch (e) {
if (e instanceof MarkdownParseError || e instanceof UnrecognizedMarkdownConstructError) {
if (e instanceof MarkdownParseError || e instanceof UnrecognizedMarkdownConstructError || e instanceof JsxKindMismatchError) {
r.pubIn({
[markdown$]: markdownValue,
[markdownProcessingError$]: {
Expand Down
25 changes: 7 additions & 18 deletions src/plugins/jsx/MdastMdxJsxElementVisitor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { $createParagraphNode, ElementNode, RootNode } from 'lexical'
import { ElementNode } from 'lexical'
import { MdxJsxTextElement } from 'mdast-util-mdx'
import { $createLexicalJsxNode } from './LexicalJsxNode'
import { MdastImportVisitor } from '../../importMarkdownToLexical'
Expand All @@ -14,22 +14,11 @@ export const MdastMdxJsxElementVisitor: MdastImportVisitor<MdxJsxTextElement | M
}
return false
},
visitNode({ lexicalParent, mdastNode, descriptors: { jsxComponentDescriptors }, metaData }) {
const descriptor =
jsxComponentDescriptors.find((descriptor) => descriptor.name === mdastNode.name) ??
jsxComponentDescriptors.find((descriptor) => descriptor.name === '*')

// the parser does not know that the node should be treated as an inline element, but our descriptor does.
if (descriptor?.kind === 'text' && mdastNode.type === 'mdxJsxFlowElement') {
const patchedNode = { ...mdastNode, type: 'mdxJsxTextElement' } as MdxJsxTextElement
const paragraph = $createParagraphNode()
paragraph.append($createLexicalJsxNode(patchedNode, mdastNode.name ? metaData.importDeclarations[mdastNode.name] : undefined))
;(lexicalParent as RootNode).append(paragraph)
} else {
;(lexicalParent as ElementNode).append(
$createLexicalJsxNode(mdastNode, mdastNode.name ? metaData.importDeclarations[mdastNode.name] : undefined)
)
}
visitNode({ lexicalParent, mdastNode, metaData }) {
;(lexicalParent as ElementNode).append(
$createLexicalJsxNode(mdastNode, mdastNode.name ? metaData.importDeclarations[mdastNode.name] : undefined)
)
},
priority: -200
priority: -200,
jsxKindReconciliationOwner: true
}
17 changes: 15 additions & 2 deletions src/plugins/jsx/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
addToMarkdownExtension$,
insertDecoratorNode$,
jsxComponentDescriptors$,
jsxKindMismatchPolicy$,
jsxIsAvailable$
} from '../core'
import { $createLexicalJsxNode, LexicalJsxNode } from './LexicalJsxNode'
Expand All @@ -24,6 +25,9 @@ import { MdastMdxExpressionVisitor } from './MdastMdxExpressionVisitor'
import { LexicalMdxExpressionNode } from './LexicalMdxExpressionNode'
import { LexicalMdxExpressionVisitor } from './LexicalMdxExpressionVisitor'
import { GenericJsxEditor } from '../../jsx-editors/GenericJsxEditor'
import type { JsxKindMismatchPolicy } from './reconcileJsxKind'

export type { JsxKindMismatchPolicy } from './reconcileJsxKind'

/**
* An MDX JSX MDAST node.
Expand Down Expand Up @@ -195,6 +199,11 @@ export interface JsxPluginParams {
* Whether or not to allow default React fragments <></> processing in MDX.
*/
allowFragment?: boolean
/**
* Controls whether the parsed JSX kind or the component descriptor is authoritative when they disagree.
* @defaultValue 'source'
*/
kindMismatchPolicy?: JsxKindMismatchPolicy
}

const fragmentDescriptor = {
Expand Down Expand Up @@ -234,11 +243,15 @@ export const jsxPlugin = realmPlugin<JsxPluginParams>({
[addLexicalNode$]: [LexicalJsxNode, LexicalMdxExpressionNode],
[addExportVisitor$]: [LexicalJsxVisitor, LexicalMdxExpressionVisitor],
[addToMarkdownExtension$]: mdxToMarkdown(),
[jsxComponentDescriptors$]: getDescriptors(params)
[jsxComponentDescriptors$]: getDescriptors(params),
[jsxKindMismatchPolicy$]: params?.kindMismatchPolicy ?? 'source'
})
},

update(realm, params) {
realm.pub(jsxComponentDescriptors$, getDescriptors(params))
realm.pubIn({
[jsxComponentDescriptors$]: getDescriptors(params),
[jsxKindMismatchPolicy$]: params?.kindMismatchPolicy ?? 'source'
})
}
})
Loading