From a5f57634bc403c55fd43fd230af64a5bba198669 Mon Sep 17 00:00:00 2001 From: Petyo Ivanov Date: Thu, 27 Aug 2026 12:58:45 +0300 Subject: [PATCH] fix: prevent JSX kind mismatches from crashing the editor --- docs/error-handling.md | 6 + docs/jsx.md | 27 ++ src/importMarkdownToLexical.ts | 38 +- src/jsx-editors/GenericJsxEditor.tsx | 2 +- src/plugins/core/index.ts | 10 +- src/plugins/jsx/MdastMdxJsxElementVisitor.ts | 25 +- src/plugins/jsx/index.ts | 17 +- src/plugins/jsx/reconcileJsxKind.ts | 238 +++++++++++ src/test/jsx.test.tsx | 396 ++++++++++++++++++- 9 files changed, 723 insertions(+), 36 deletions(-) create mode 100644 src/plugins/jsx/reconcileJsxKind.ts diff --git a/docs/error-handling.md b/docs/error-handling.md index 2b7aa387..79d9d71c 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -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. diff --git a/docs/jsx.md b/docs/jsx.md index 464b5c11..0a48697f 100644 --- a/docs/jsx.md +++ b/docs/jsx.md @@ -107,6 +107,33 @@ more Content ``` +## 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 + Text +``` + +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: diff --git a/src/importMarkdownToLexical.ts b/src/importMarkdownToLexical.ts index 49766f13..7f718262 100644 --- a/src/importMarkdownToLexical.ts +++ b/src/importMarkdownToLexical.ts @@ -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 @@ -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[] /** @@ -122,6 +125,8 @@ export interface MdastImportVisitor { * 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 { @@ -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 | 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() const styleMap = new WeakMap() 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') @@ -263,15 +287,7 @@ export function importMdastTreeToLexical({ root, mdastRoot, visitors, ...descrip mdastParent: Mdast.Parent | null, skipVisitors: Set | 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)}`) diff --git a/src/jsx-editors/GenericJsxEditor.tsx b/src/jsx-editors/GenericJsxEditor.tsx index 186e249f..ba810e87 100644 --- a/src/jsx-editors/GenericJsxEditor.tsx +++ b/src/jsx-editors/GenericJsxEditor.tsx @@ -124,7 +124,7 @@ export const GenericJsxEditor: React.FC = ({ mdastNode, d {descriptor.hasChildren ? ( - block={descriptor.kind === 'flow'} + block={mdastNode.type === 'mdxJsxFlowElement'} getContent={(node) => node.children as PhrasingContent[]} getUpdatedMdastNode={(mdastNode, children) => { return { ...mdastNode, children } as any diff --git a/src/plugins/core/index.ts b/src/plugins/core/index.ts index 477635d7..c69e87e7 100644 --- a/src/plugins/core/index.ts +++ b/src/plugins/core/index.ts @@ -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' @@ -287,6 +288,12 @@ export const jsxIsAvailable$ = Cell(false) */ export const jsxComponentDescriptors$ = Cell([]) +/** + * Controls how parsed JSX node kinds are reconciled with component descriptors. + * @group JSX + */ +export const jsxKindMismatchPolicy$ = Cell('source') + /** * Contains the currently registered Markdown directive descriptors. * @group Directive @@ -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$]: { diff --git a/src/plugins/jsx/MdastMdxJsxElementVisitor.ts b/src/plugins/jsx/MdastMdxJsxElementVisitor.ts index f1c9646e..d698c2ea 100644 --- a/src/plugins/jsx/MdastMdxJsxElementVisitor.ts +++ b/src/plugins/jsx/MdastMdxJsxElementVisitor.ts @@ -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' @@ -14,22 +14,11 @@ export const MdastMdxJsxElementVisitor: MdastImportVisitor 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 } diff --git a/src/plugins/jsx/index.ts b/src/plugins/jsx/index.ts index 2d56ff3d..1ff29fc7 100644 --- a/src/plugins/jsx/index.ts +++ b/src/plugins/jsx/index.ts @@ -11,6 +11,7 @@ import { addToMarkdownExtension$, insertDecoratorNode$, jsxComponentDescriptors$, + jsxKindMismatchPolicy$, jsxIsAvailable$ } from '../core' import { $createLexicalJsxNode, LexicalJsxNode } from './LexicalJsxNode' @@ -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. @@ -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 = { @@ -234,11 +243,15 @@ export const jsxPlugin = realmPlugin({ [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' + }) } }) diff --git a/src/plugins/jsx/reconcileJsxKind.ts b/src/plugins/jsx/reconcileJsxKind.ts new file mode 100644 index 00000000..4fd190c8 --- /dev/null +++ b/src/plugins/jsx/reconcileJsxKind.ts @@ -0,0 +1,238 @@ +import * as Mdast from 'mdast' +import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx-jsx' +import type { JsxComponentDescriptor } from '.' + +/** + * Controls how parsed JSX node kinds are reconciled with component descriptors. + * @group JSX + */ +export type JsxKindMismatchPolicy = 'source' | 'normalize' | 'error' + +export class JsxKindMismatchError extends Error { + constructor( + node: MdxJsxFlowElement | MdxJsxTextElement, + descriptor: JsxComponentDescriptor, + policy: Exclude, + reason?: string + ) { + const name = node.name ?? 'Fragment' + const parsedKind = node.type === 'mdxJsxFlowElement' ? 'flow' : 'text' + const detail = reason ? ` ${reason}` : '' + super( + `JSX component "${name}" was parsed as ${parsedKind} but is declared as ${descriptor.kind} ` + + `(kindMismatchPolicy: "${policy}").${detail}` + ) + this.name = 'JsxKindMismatchError' + } +} + +const phrasingParentTypes = new Set([ + 'paragraph', + 'heading', + 'emphasis', + 'strong', + 'delete', + 'link', + 'linkReference', + 'tableCell', + 'footnote', + 'mdxJsxTextElement', + 'textDirective', + 'leafDirective' +]) + +const flowParentTypes = new Set(['root', 'blockquote', 'listItem', 'footnoteDefinition', 'mdxJsxFlowElement', 'containerDirective']) + +function isJsxNode(node: Mdast.Nodes): node is MdxJsxFlowElement | MdxJsxTextElement { + return node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement' +} + +function findDescriptor(node: MdxJsxFlowElement | MdxJsxTextElement, descriptors: JsxComponentDescriptor[]) { + return descriptors.find((descriptor) => descriptor.name === node.name) ?? descriptors.find((descriptor) => descriptor.name === '*') +} + +function isMismatch(node: MdxJsxFlowElement | MdxJsxTextElement, descriptor: JsxComponentDescriptor) { + return (node.type === 'mdxJsxFlowElement' ? 'flow' : 'text') !== descriptor.kind +} + +function assertNoMismatches( + node: Mdast.Nodes, + descriptors: JsxComponentDescriptor[], + shouldReconcile: (node: MdxJsxFlowElement | MdxJsxTextElement) => boolean +) { + if (isJsxNode(node)) { + const descriptor = findDescriptor(node, descriptors) + if (descriptor && shouldReconcile(node) && isMismatch(node, descriptor)) { + throw new JsxKindMismatchError(node, descriptor, 'error') + } + } + + if ('children' in node) { + node.children.forEach((child) => { + assertNoMismatches(child, descriptors, shouldReconcile) + }) + } +} + +function normalizeTree( + root: Mdast.Root, + descriptors: JsxComponentDescriptor[], + shouldReconcile: (node: MdxJsxFlowElement | MdxJsxTextElement) => boolean +) { + const findOwnedDescriptor = (node: MdxJsxFlowElement | MdxJsxTextElement) => + shouldReconcile(node) ? findDescriptor(node, descriptors) : undefined + + function normalizeParent(node: T): T { + const context = phrasingParentTypes.has(node.type) ? 'phrasing' : flowParentTypes.has(node.type) ? 'flow' : 'structural' + + if (context === 'phrasing') { + node.children = node.children.flatMap((child) => normalizePhrasingChild(child, node.type)) as T['children'] + } else if (context === 'flow') { + node.children = node.children.flatMap((child) => normalizeFlowChild(child)) as T['children'] + } else { + node.children = node.children.map((child) => normalizeStructuralChild(child, node.type)) as T['children'] + } + + return node + } + + function normalizeDescendants(node: T): T { + if ('children' in node) { + normalizeParent(node) + } + return node + } + + function normalizeStructuralChild(node: T, parentType: string): T { + if (isJsxNode(node)) { + const descriptor = findOwnedDescriptor(node) + if (descriptor && isMismatch(node, descriptor)) { + throw new JsxKindMismatchError( + node, + descriptor, + 'normalize', + `The ${parentType} parent does not declare whether its children are flow or phrasing content.` + ) + } + } + return normalizeDescendants(node) + } + + function normalizePhrasingChild(node: Mdast.Nodes, parentType: string): Mdast.Nodes[] { + if (!isJsxNode(node)) { + return [normalizeDescendants(node)] + } + + const descriptor = findOwnedDescriptor(node) + if (!descriptor || !isMismatch(node, descriptor)) { + return [normalizeDescendants(node)] + } + + if (node.type === 'mdxJsxTextElement') { + throw new JsxKindMismatchError( + node, + descriptor, + 'normalize', + `It cannot be lifted from ${parentType} without changing surrounding phrasing content.` + ) + } + + const textNode = convertFlowToText(node, descriptor) + return [normalizeDescendants(textNode)] + } + + function normalizeParagraph(node: Mdast.Paragraph): Mdast.RootContent[] { + const flowMismatch = node.children.find((child) => { + if (child.type !== 'mdxJsxTextElement') { + return false + } + const descriptor = findOwnedDescriptor(child) + return descriptor?.kind === 'flow' + }) as MdxJsxTextElement | undefined + + if (!flowMismatch) { + return [normalizeParent(node)] + } + + const descriptor = findOwnedDescriptor(flowMismatch)! + if (node.children.length !== 1) { + throw new JsxKindMismatchError( + flowMismatch, + descriptor, + 'normalize', + 'It has surrounding phrasing content, and normalization would need to split the paragraph.' + ) + } + + const flowNode: MdxJsxFlowElement = { + ...flowMismatch, + type: 'mdxJsxFlowElement', + children: flowMismatch.children.length === 0 ? [] : [{ type: 'paragraph', children: flowMismatch.children }] + } + return [normalizeDescendants(flowNode)] + } + + function normalizeFlowChild(node: Mdast.Nodes): Mdast.Nodes[] { + if (node.type === 'paragraph') { + return normalizeParagraph(node) + } + + if (!isJsxNode(node)) { + return [normalizeDescendants(node)] + } + + const descriptor = findOwnedDescriptor(node) + if (!descriptor || !isMismatch(node, descriptor)) { + return [normalizeDescendants(node)] + } + + if (node.type === 'mdxJsxTextElement') { + throw new JsxKindMismatchError( + node, + descriptor, + 'normalize', + 'A text JSX node can be normalized to flow only when it is the sole child of a paragraph.' + ) + } + + const textNode = normalizeDescendants(convertFlowToText(node, descriptor)) + return [{ type: 'paragraph', children: [textNode] }] + } + + function convertFlowToText(node: MdxJsxFlowElement, descriptor: JsxComponentDescriptor): MdxJsxTextElement { + if (node.children.length === 0) { + return { ...node, type: 'mdxJsxTextElement', children: [] } + } + + if (node.children.length !== 1 || node.children[0].type !== 'paragraph') { + throw new JsxKindMismatchError( + node, + descriptor, + 'normalize', + 'Its block children cannot be represented as one phrasing-content sequence without data loss.' + ) + } + + return { ...node, type: 'mdxJsxTextElement', children: node.children[0].children } + } + + return normalizeParent(root) +} + +export function reconcileJsxKindMismatches( + root: Mdast.Root, + descriptors: JsxComponentDescriptor[], + policy: JsxKindMismatchPolicy, + shouldReconcile: (node: MdxJsxFlowElement | MdxJsxTextElement) => boolean = () => true +): Mdast.Root { + if (policy === 'source') { + return root + } + + if (policy === 'error') { + assertNoMismatches(root, descriptors, shouldReconcile) + return root + } + + return normalizeTree(structuredClone(root), descriptors, shouldReconcile) +} diff --git a/src/test/jsx.test.tsx b/src/test/jsx.test.tsx index 60f0d511..43806863 100644 --- a/src/test/jsx.test.tsx +++ b/src/test/jsx.test.tsx @@ -1,7 +1,20 @@ import React from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { GenericJsxEditor, JsxComponentDescriptor, MDXEditor, MDXEditorMethods, jsxPlugin } from '../' -import { render, act, waitFor } from '@testing-library/react' +import { addNestedEditorChild$, GenericJsxEditor, JsxComponentDescriptor, MDXEditor, MDXEditorMethods, jsxPlugin, realmPlugin } from '../' +import { render, act, fireEvent, waitFor } from '@testing-library/react' +import { fromMarkdown } from 'mdast-util-from-markdown' +import { mdxFromMarkdown } from 'mdast-util-mdx' +import { mdxjs } from 'micromark-extension-mdxjs' +import { JsxKindMismatchError, reconcileJsxKindMismatches } from '../plugins/jsx/reconcileJsxKind' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { $createParagraphNode, $createTextNode, $getRoot, createEditor, ParagraphNode, TextNode, type LexicalEditor } from 'lexical' +import type * as Mdast from 'mdast' +import { importMdastTreeToLexical, type MdastTreeImportOptions } from '../importMarkdownToLexical' +import { MdastRootVisitor } from '../plugins/core/MdastRootVisitor' +import { MdastParagraphVisitor } from '../plugins/core/MdastParagraphVisitor' +import { MdastTextVisitor } from '../plugins/core/MdastTextVisitor' +import { $isLexicalJsxNode, LexicalJsxNode } from '../plugins/jsx/LexicalJsxNode' +import { MdastMdxJsxElementVisitor } from '../plugins/jsx/MdastMdxJsxElementVisitor' // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access ;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true @@ -29,6 +42,31 @@ const flushEditorUpdates = async () => { }) } +const issue962Markdown = ' Fotorückblick 2001 + 2002' + +const issue962Descriptors: JsxComponentDescriptor[] = [ + { + name: 'Card.Header', + kind: 'flow', + props: [{ name: 'as', type: 'string' }], + hasChildren: true, + Editor: GenericJsxEditor + }, + { + name: 'Icon', + kind: 'text', + props: [{ name: 'name', type: 'string' }], + hasChildren: false, + Editor: GenericJsxEditor + } +] + +const parseMdx = (markdown: string) => + fromMarkdown(markdown, { + extensions: [mdxjs()], + mdastExtensions: [mdxFromMarkdown()] + }) + describe('jsx markdown import export', () => { // produces a warning about act it.todo('skips jsx import if not specified', async () => { @@ -102,6 +140,358 @@ describe('jsx markdown import export', () => { const processedMarkdown = ref.current?.getMarkdown() ?? '' expect(processedMarkdown).toContain(`import { Wrapper, Section } from './components'`) - expect(processedMarkdown).toContain(`
`) + expect(processedMarkdown).toContain(`\n
\n`) + }) + + it('preserves parser semantics by default when a descriptor kind differs from the parsed JSX kind', async () => { + let nestedEditor: LexicalEditor | null = null + function CaptureNestedEditor() { + ;[nestedEditor] = useLexicalComposerContext() + return null + } + const captureNestedEditorPlugin = realmPlugin({ + init(realm) { + realm.pub(addNestedEditorChild$, CaptureNestedEditor) + } + }) + const ref = React.createRef() + const onError = vi.fn() + const { container } = render( + + ) + + await waitFor(() => { + expect(container.querySelectorAll('[contenteditable="true"]')).toHaveLength(2) + expect(nestedEditor).not.toBeNull() + }) + const nestedEditorElement = container.querySelectorAll('[contenteditable="true"]')[1] + expect(nestedEditorElement.textContent).toContain('Fotorückblick 2001 + 2002') + + act(() => { + nestedEditor!.update( + () => { + $getRoot() + .clear() + .append($createParagraphNode().append($createTextNode('Changed in the nested editor'))) + }, + { discrete: true } + ) + }) + fireEvent.blur(nestedEditorElement) + + await waitFor(() => { + expect(ref.current?.getMarkdown()).toBe('Changed in the nested editor') + }) + expect(onError).not.toHaveBeenCalled() + }) + + it('preserves a parsed flow JSX kind when the descriptor declares text by default', async () => { + const markdown = '\ncontent\n' + const descriptors: JsxComponentDescriptor[] = [{ name: 'Badge', kind: 'text', props: [], hasChildren: true, Editor: GenericJsxEditor }] + const ref = React.createRef() + const onError = vi.fn() + const { container } = render( + + ) + + await waitFor(() => { + expect(container.querySelectorAll('[contenteditable="true"]')).toHaveLength(2) + }) + fireEvent.blur(container.querySelectorAll('[contenteditable="true"]')[1]) + + await waitFor(() => { + expect(parseMdx(ref.current?.getMarkdown() ?? '').children[0]?.type).toBe('mdxJsxFlowElement') + }) + expect(onError).not.toHaveBeenCalled() + }) + + it('does not apply a wildcard JSX policy to elements owned by the HTML visitor', async () => { + const onError = vi.fn() + const descriptors: JsxComponentDescriptor[] = [{ name: '*', kind: 'flow', props: [], hasChildren: true, Editor: GenericJsxEditor }] + const { container } = render( + + ) + + await waitFor(() => { + expect(container.querySelector('span')?.textContent).toBe('text') + }) + expect(onError).not.toHaveBeenCalled() + }) + + it('applies normalization to direct MDAST tree imports', () => { + const editor = createEditor({ + namespace: 'jsx-kind-normalization-test', + nodes: [ParagraphNode, TextNode, LexicalJsxNode], + onError(error) { + throw error + } + }) + + editor.update( + () => { + importMdastTreeToLexical({ + root: $getRoot(), + mdastRoot: parseMdx(issue962Markdown), + visitors: [ + MdastRootVisitor, + MdastParagraphVisitor, + MdastTextVisitor, + MdastMdxJsxElementVisitor + ] as unknown as MdastTreeImportOptions['visitors'], + jsxComponentDescriptors: issue962Descriptors, + jsxKindMismatchPolicy: 'normalize', + directiveDescriptors: [], + codeBlockEditorDescriptors: [], + defaultCodeBlockLanguage: '' + }) + }, + { discrete: true } + ) + + editor.getEditorState().read(() => { + const jsxNode = $getRoot().getFirstChild() + expect($isLexicalJsxNode(jsxNode)).toBe(true) + if (!$isLexicalJsxNode(jsxNode)) { + throw new Error('Expected a JSX node') + } + expect(jsxNode.getMdastNode().type).toBe('mdxJsxFlowElement') + }) + }) + + it('normalizes a standalone text JSX element to its declared flow kind', async () => { + const parsed = parseMdx(issue962Markdown) + const normalized = reconcileJsxKindMismatches(parsed, issue962Descriptors, 'normalize') + const card = normalized.children[0] + + expect(parsed.children[0].type).toBe('paragraph') + expect(card.type).toBe('mdxJsxFlowElement') + if (card.type !== 'mdxJsxFlowElement') { + throw new Error('Expected a flow JSX element') + } + expect(card.children).toHaveLength(1) + expect(card.children[0]).toMatchObject({ + type: 'paragraph', + children: [ + { type: 'mdxJsxTextElement', name: 'Icon' }, + { type: 'text', value: ' Fotorückblick 2001 + 2002' } + ] + }) + expect(reconcileJsxKindMismatches(normalized, issue962Descriptors, 'normalize')).toEqual(normalized) + + const formatted = reconcileJsxKindMismatches( + parseMdx('*emphasis* and `code`'), + issue962Descriptors, + 'normalize' + ) + expect(formatted.children[0]).toMatchObject({ + type: 'mdxJsxFlowElement', + children: [ + { + type: 'paragraph', + children: [{ type: 'emphasis' }, { type: 'text', value: ' and ' }, { type: 'inlineCode', value: 'code' }] + } + ] + }) + + const ref = React.createRef() + const onError = vi.fn() + const { container } = render( + + ) + + await waitFor(() => { + expect(container.querySelectorAll('[contenteditable="true"]')).toHaveLength(2) + }) + fireEvent.blur(container.querySelectorAll('[contenteditable="true"]')[1]) + + await waitFor(() => { + const exported = ref.current?.getMarkdown() ?? '' + expect(parseMdx(exported).children[0]?.type).toBe('mdxJsxFlowElement') + expect(exported).toContain('Fotorückblick 2001 + 2002') + }) + expect(onError).not.toHaveBeenCalled() + }) + + it('normalizes one flow paragraph to a declared text JSX kind', async () => { + const descriptors: JsxComponentDescriptor[] = [{ name: 'Badge', kind: 'text', props: [], hasChildren: true, Editor: GenericJsxEditor }] + const markdown = '\ncontent\n' + const normalized = reconcileJsxKindMismatches(parseMdx(markdown), descriptors, 'normalize') + + expect(normalized.children[0]).toMatchObject({ + type: 'paragraph', + children: [{ type: 'mdxJsxTextElement', name: 'Badge', children: [{ type: 'text', value: 'content' }] }] + }) + + const ref = React.createRef() + const onError = vi.fn() + const { container } = render( + + ) + await waitFor(() => { + expect(container.querySelectorAll('[contenteditable="true"]')).toHaveLength(2) + }) + fireEvent.blur(container.querySelectorAll('[contenteditable="true"]')[1]) + await waitFor(() => { + const reparsed = parseMdx(ref.current?.getMarkdown() ?? '') + expect(reparsed.children[0]).toMatchObject({ + type: 'paragraph', + children: [{ type: 'mdxJsxTextElement', name: 'Badge' }] + }) + }) + expect(onError).not.toHaveBeenCalled() + }) + + it('normalizes an empty flow JSX element to text without data loss', () => { + const descriptors: JsxComponentDescriptor[] = [{ name: 'Badge', kind: 'text', props: [], hasChildren: true, Editor: GenericJsxEditor }] + const normalized = reconcileJsxKindMismatches(parseMdx(''), descriptors, 'normalize') + + expect(normalized.children[0]).toMatchObject({ + type: 'paragraph', + children: [{ type: 'mdxJsxTextElement', name: 'Badge', children: [] }] + }) + }) + + it.each([ + { + name: 'a block descriptor inside a heading', + markdown: '# content', + descriptors: [{ name: 'Block', kind: 'flow', props: [], hasChildren: true, Editor: GenericJsxEditor }] + }, + { + name: 'a text descriptor containing a non-paragraph block', + markdown: '\n# Heading\n', + descriptors: [{ name: 'Inline', kind: 'text', props: [], hasChildren: true, Editor: GenericJsxEditor }] + } + ])('rejects normalization for $name', ({ markdown, descriptors }) => { + expect(() => reconcileJsxKindMismatches(parseMdx(markdown), descriptors as JsxComponentDescriptor[], 'normalize')).toThrowError( + JsxKindMismatchError + ) + }) + + it('rejects a mismatch under an MDAST parent with an unknown content model', () => { + const jsxNode = (parseMdx('content').children[0] as Mdast.Paragraph).children[0] + const tree = { + type: 'root', + children: [{ type: 'customParent', children: [jsxNode] }] + } as unknown as Mdast.Root + const descriptors: JsxComponentDescriptor[] = [{ name: 'Block', kind: 'flow', props: [], hasChildren: true, Editor: GenericJsxEditor }] + + expect(() => reconcileJsxKindMismatches(tree, descriptors, 'normalize')).toThrowError( + 'customParent parent does not declare whether its children are flow or phrasing content' + ) + }) + + it('applies an updated mismatch policy to later Markdown imports', async () => { + const ref = React.createRef() + const { container, rerender } = render( + + ) + + rerender( + + ) + await flushEditorUpdates() + act(() => { + ref.current?.setMarkdown(issue962Markdown) + }) + + await waitFor(() => { + expect(container.querySelectorAll('[contenteditable="true"]')).toHaveLength(2) + }) + fireEvent.blur(container.querySelectorAll('[contenteditable="true"]')[1]) + await waitFor(() => { + expect(parseMdx(ref.current?.getMarkdown() ?? '').children[0]?.type).toBe('mdxJsxFlowElement') + }) + }) + + it.each([ + { + name: 'a block descriptor embedded in surrounding phrasing', + markdown: 'Before content after', + descriptors: [{ name: 'Block', kind: 'flow', props: [], hasChildren: true, Editor: GenericJsxEditor }] + }, + { + name: 'a text descriptor with multiple flow blocks', + markdown: '\nfirst\n\nsecond\n', + descriptors: [{ name: 'Inline', kind: 'text', props: [], hasChildren: true, Editor: GenericJsxEditor }] + } + ])('reports an actionable normalization error for $name', async ({ markdown, descriptors }) => { + const ref = React.createRef() + const onError = vi.fn<[payload: { error: string; source: string }]>() + render( + + ) + + await waitFor(() => { + expect(onError).toHaveBeenCalledOnce() + }) + expect(onError.mock.calls[0][0]).toMatchObject({ source: markdown }) + expect(onError.mock.calls[0][0].error).toContain('kindMismatchPolicy: "normalize"') + expect(ref.current?.getMarkdown()).toBe(markdown) + }) + + it('rejects named, fragment, and wildcard descriptor mismatches in strict mode', async () => { + const namedDescriptors: JsxComponentDescriptor[] = [ + { name: 'Block', kind: 'flow', props: [], hasChildren: true, Editor: GenericJsxEditor } + ] + const wildcardDescriptors: JsxComponentDescriptor[] = [ + { name: '*', kind: 'flow', props: [], hasChildren: true, Editor: GenericJsxEditor } + ] + const fragmentDescriptors: JsxComponentDescriptor[] = [ + { name: null, kind: 'flow', props: [], hasChildren: true, Editor: GenericJsxEditor } + ] + const parsed = parseMdx('content') + + expect(() => reconcileJsxKindMismatches(parsed, namedDescriptors, 'error')).toThrowError(JsxKindMismatchError) + expect(() => reconcileJsxKindMismatches(parseMdx('<>content'), fragmentDescriptors, 'error')).toThrowError(JsxKindMismatchError) + expect(() => reconcileJsxKindMismatches(parseMdx('content'), wildcardDescriptors, 'error')).toThrowError( + JsxKindMismatchError + ) + expect(() => reconcileJsxKindMismatches(parseMdx('\ncontent\n'), namedDescriptors, 'error')).not.toThrow() + + const onError = vi.fn<[payload: { error: string; source: string }]>() + render( + content'} + onError={onError} + plugins={[jsxPlugin({ jsxComponentDescriptors: namedDescriptors, kindMismatchPolicy: 'error' })]} + /> + ) + await waitFor(() => { + expect(onError).toHaveBeenCalledOnce() + }) + expect(onError.mock.calls[0][0].error).toContain('kindMismatchPolicy: "error"') }) })