diff --git a/docs/error-handling.md b/docs/error-handling.md new file mode 100644 index 00000000..9ab105ea --- /dev/null +++ b/docs/error-handling.md @@ -0,0 +1,25 @@ +--- +title: Error handling +slug: error-handling +position: 100 +--- + +# Handling Errors + +The markdown format can be complex due to its loose nature and you may integrate the editor in on top of existing content that you have no full control over. In this article, we will go over the error types that the editor can produce, the actual reasons behind them, and how to handle them. + +## Errors caused by an invalid markdown format + +The editor component uses the [MDAST library](https://github.com/syntax-tree/mdast-util-from-markdown) to parse the markdown content. Although it's quite forgiving, certain content can cause the parsing to fail, in which case the editor will remain empty. To obtain more information about the error, you can pass a callback to the `onError` prop - the callback will receive a payload that includes the error message and the source markdown that triggered it. + +### Parse errors caused by HTML-like formatting (e.g. HTML comments, or links surrounded by angle brackets) + +To handle common basic HTML formatting (e.g. `u` tags), the default parsing includes the [mdast-util-mdx-jsx extension](https://github.com/syntax-tree/mdast-util-mdx-jsx). In some cases, this can cause the parsing to fail. You can disable this extension by setting the `suppressHtmlProcessing` prop to `true`, but you will lose the ability to use HTML-like formatting in your markdown. + +## Errors due to missing plugins + +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 register a mdast visitor with low priority that will handle all unknown nodes. See `./extending-the-editor` for more information. + +## 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/src/MDXEditor.tsx b/src/MDXEditor.tsx index 7ad8fd28..9080fe05 100644 --- a/src/MDXEditor.tsx +++ b/src/MDXEditor.tsx @@ -88,6 +88,10 @@ export interface MDXEditorProps { * if you intend to do auto-saving. */ onChange?: (markdown: string) => void + /** + * Triggered when the markdown parser encounters an error. The payload includes the invalid source and the error message. + */ + onError?: (payload: { error: string; source: string }) => void /** * The markdown options used to generate the resulting markdown. * See {@link https://github.com/syntax-tree/mdast-util-to-markdown#options | the mdast-util-to-markdown docs} for the full list of options. @@ -123,6 +127,10 @@ export interface MDXEditorProps { * Use this prop to customize the icons used across the editor. Pass a function that returns an icon (JSX) for a given icon key. */ iconComponentFor?: (name: IconKey) => JSX.Element + /** + * Set to false if you want to suppress the processing of HTML tags. + */ + suppressHtmlProcessing?: boolean } const DEFAULT_MARKDOWN_OPTIONS: ToMarkdownOptions = { @@ -245,7 +253,9 @@ export const MDXEditor = React.forwardRef((pro autoFocus: props.autoFocus ?? false, placeholder: props.placeholder ?? '', readOnly: Boolean(props.readOnly), - iconComponentFor: props.iconComponentFor ?? defaultIconComponentFor + iconComponentFor: props.iconComponentFor ?? defaultIconComponentFor, + suppressHtmlProcessing: props.suppressHtmlProcessing ?? false, + onError: props.onError ?? noop }), ...(props.plugins || []) ]} diff --git a/src/examples/assets/buggy-markdown.md b/src/examples/assets/buggy-markdown.md new file mode 100644 index 00000000..0c83e706 --- /dev/null +++ b/src/examples/assets/buggy-markdown.md @@ -0,0 +1,13 @@ +## Job Applications winter 2021 + +* Macquarie commodities trader +* BBC AI postgrad +* Canva +* Orsted +* Aldi +* Amazon +* EY +* crossing minds +* +* ibm +* \ No newline at end of file diff --git a/src/examples/error-handling.tsx b/src/examples/error-handling.tsx new file mode 100644 index 00000000..c71c5b68 --- /dev/null +++ b/src/examples/error-handling.tsx @@ -0,0 +1,58 @@ +import React from 'react' +import { + BoldItalicUnderlineToggles, + ChangeCodeMirrorLanguage, + ConditionalContents, + DiffSourceToggleWrapper, + MDXEditor, + ShowSandpackInfo, + UndoRedo, + diffSourcePlugin, + toolbarPlugin +} from '../' +import markdown from './assets/buggy-markdown.md?raw' +import { ALL_PLUGINS } from './_boilerplate' + +export function BuggyMarkdown() { + return ( + console.warn(msg)} + markdown={markdown} + onChange={(md) => console.log('change', { md })} + plugins={ALL_PLUGINS} + /> + ) +} + +export function MissingPlugins() { + return ( + console.warn(msg)} + markdown={`# Hello`} + onChange={(md) => console.log('change', { md })} + plugins={[ + toolbarPlugin({ + toolbarContents: () => ( + + editor?.editorType === 'codeblock', contents: () => }, + { when: (editor) => editor?.editorType === 'sandpack', contents: () => }, + { + fallback: () => ( + <> + + + + ) + } + ]} + /> + + ) + }), + diffSourcePlugin({ viewMode: 'rich-text', diffMarkdown: 'boo' }) + ]} + /> + ) +} diff --git a/src/importMarkdownToLexical.ts b/src/importMarkdownToLexical.ts index c4c73f33..a4eb1400 100644 --- a/src/importMarkdownToLexical.ts +++ b/src/importMarkdownToLexical.ts @@ -2,6 +2,7 @@ import { ElementNode, LexicalNode, RootNode as LexicalRootNode } from 'lexical' import * as Mdast from 'mdast' import { fromMarkdown } from 'mdast-util-from-markdown' +import { toMarkdown } from 'mdast-util-to-markdown' import { ParseOptions } from 'micromark-util-types' import { IS_BOLD, IS_CODE, IS_ITALIC, IS_UNDERLINE } from './FormatConstants' @@ -105,12 +106,36 @@ export type MdastExtension = NonNullable[number] */ export type SyntaxExtension = MarkdownParseOptions['syntaxExtensions'][number] +export class MarkdownParseError extends Error { + constructor(message: string, cause: unknown) { + super(message) + this.name = 'MarkdownParseError' + this.cause = cause + } +} + +export class UnrecognizedMarkdownConstructError extends Error { + constructor(message: string) { + super(message) + this.name = 'UnrecognizedMarkdownConstructError' + } +} + /** @internal */ export function importMarkdownToLexical({ root, markdown, visitors, syntaxExtensions, mdastExtensions }: MarkdownParseOptions): void { - const mdastRoot = fromMarkdown(markdown, { - extensions: syntaxExtensions, - mdastExtensions - }) + let mdastRoot: Mdast.Root + try { + mdastRoot = fromMarkdown(markdown, { + extensions: syntaxExtensions, + mdastExtensions + }) + } catch (e: unknown) { + if (e instanceof Error) { + throw new MarkdownParseError(`Error parsing markdown: ${e.message}`, e) + } else { + throw new MarkdownParseError(`Error parsing markdown: ${e}`, e) + } + } if (mdastRoot.children.length === 0) { mdastRoot.children.push({ type: 'paragraph', children: [] }) @@ -145,9 +170,7 @@ export function importMdastTreeToLexical({ root, mdastRoot, visitors }: MdastTre return visitor.testNode(mdastNode) }) if (!visitor) { - throw new Error(`no MdastImportVisitor found for ${mdastNode.type} ${JSON.stringify(mdastNode)}`, { - cause: mdastNode - }) + throw new UnrecognizedMarkdownConstructError(`Unsupported markdown syntax: ${toMarkdown(mdastNode)}`) } visitor.visitNode({ diff --git a/src/plugins/core/index.ts b/src/plugins/core/index.ts index 5fd162f4..f994736e 100644 --- a/src/plugins/core/index.ts +++ b/src/plugins/core/index.ts @@ -33,7 +33,13 @@ import * as Mdast from 'mdast' import React from 'react' import { LexicalConvertOptions, exportMarkdownFromLexical } from '../../exportMarkdownFromLexical' import { RealmNode, realmPlugin, system } from '../../gurx' -import { MarkdownParseOptions, MdastImportVisitor, importMarkdownToLexical } from '../../importMarkdownToLexical' +import { + MarkdownParseError, + MarkdownParseOptions, + MdastImportVisitor, + UnrecognizedMarkdownConstructError, + importMarkdownToLexical +} from '../../importMarkdownToLexical' import type { JsxComponentDescriptor } from '../jsx' import { LexicalLinebreakVisitor } from './LexicalLinebreakVisitor' import { LexicalParagraphVisitor } from './LexicalParagraphVisitor' @@ -107,6 +113,16 @@ export const coreSystem = system((r) => { const autoFocus = r.node(false) const inFocus = r.node(false, true) const currentFormat = r.node(0, true) + const markdownProcessingError = r.node<{ error: string; source: string } | null>(null) + const markdownErrorSignal = r.node<{ error: string; source: string }>() + + r.link( + r.pipe( + markdownProcessingError, + r.o.filter((e) => e !== null) + ), + markdownErrorSignal + ) const applyFormat = r.node() const currentSelection = r.node(null) @@ -179,6 +195,10 @@ export const coreSystem = system((r) => { // Export handler r.pub(createRootEditorSubscription, (theRootEditor) => { return theRootEditor.registerUpdateListener(({ dirtyElements, dirtyLeaves, editorState }) => { + const err = r.getValue(markdownProcessingError) + if (err !== null) { + return + } if (dirtyElements.size === 0 && dirtyLeaves.size === 0) { return } @@ -233,24 +253,46 @@ export const coreSystem = system((r) => { const addToMarkdownExtension = createAppendNodeFor(toMarkdownExtensions) const setMarkdown = r.node() + function tryImportingMarkdown(markdownValue: string) { + try { + //////////////////////// + // Import initial value + //////////////////////// + importMarkdownToLexical({ + root: $getRoot(), + visitors: r.getValue(importVisitors), + mdastExtensions: r.getValue(mdastExtensions), + markdown: markdownValue, + syntaxExtensions: r.getValue(syntaxExtensions) + }) + r.pub(markdownProcessingError, null) + } catch (e) { + if (e instanceof MarkdownParseError || e instanceof UnrecognizedMarkdownConstructError) { + r.pubIn({ + [markdown.key]: markdownValue, + [markdownProcessingError.key]: { + error: e.message, + source: markdown + } + }) + } else { + throw e + } + } + } + r.sub( r.pipe( setMarkdown, - r.o.withLatestFrom(markdown, rootEditor, importVisitors, mdastExtensions, syntaxExtensions, inFocus), + r.o.withLatestFrom(markdown, rootEditor, inFocus), r.o.filter(([newMarkdown, oldMarkdown]) => { return newMarkdown.trim() !== oldMarkdown.trim() }) ), - ([theNewMarkdownValue, , editor, importVisitors, mdastExtensions, syntaxExtensions, inFocus]) => { + ([theNewMarkdownValue, , editor, inFocus]) => { editor?.update(() => { $getRoot().clear() - importMarkdownToLexical({ - root: $getRoot(), - visitors: importVisitors, - mdastExtensions, - markdown: theNewMarkdownValue, - syntaxExtensions - }) + tryImportingMarkdown(theNewMarkdownValue) if (!inFocus) { $setSelection(null) @@ -266,16 +308,7 @@ export const coreSystem = system((r) => { r.pub(rootEditor, theRootEditor) r.pub(activeEditor, theRootEditor) - //////////////////////// - // Import initial value - //////////////////////// - importMarkdownToLexical({ - root: $getRoot(), - visitors: r.getValue(importVisitors), - mdastExtensions: r.getValue(mdastExtensions), - markdown: r.getValue(initialMarkdown), - syntaxExtensions: r.getValue(syntaxExtensions) - }) + tryImportingMarkdown(r.getValue(initialMarkdown)) const autoFocusValue = r.getValue(autoFocus) if (autoFocusValue) { @@ -540,7 +573,11 @@ export const coreSystem = system((r) => { // Events onBlur, - iconComponentFor + iconComponentFor, + + // error handling + markdownProcessingError, + markdownErrorSignal } }, []) @@ -551,9 +588,11 @@ interface CorePluginParams { autoFocus: boolean | { defaultSelection?: 'rootStart' | 'rootEnd'; preventScroll?: boolean | undefined } onChange: (markdown: string) => void onBlur?: (e: FocusEvent) => void + onError?: (payload: { error: string; source: string }) => void toMarkdownOptions: NonNullable readOnly: boolean iconComponentFor: (name: IconKey) => React.ReactElement + suppressHtmlProcessing?: boolean } export const [ @@ -575,17 +614,13 @@ export const [ }) realm.singletonSubKey('markdownSignal', params.onChange) realm.singletonSubKey('onBlur', params.onBlur) + realm.singletonSubKey('markdownErrorSignal', params.onError) }, init(realm, params: CorePluginParams) { realm.pubKey('initialMarkdown', params.initialMarkdown.trim()) realm.pubKey('iconComponentFor', params.iconComponentFor) - // Use the JSX extension to parse HTML - realm.pubKey('addMdastExtension', mdxJsxFromMarkdown()) - realm.pubKey('addSyntaxExtension', mdxJsx()) - realm.pubKey('addToMarkdownExtension', mdxJsxToMarkdown()) - // core import visitors realm.pubKey('addImportVisitor', MdastRootVisitor) realm.pubKey('addImportVisitor', MdastParagraphVisitor) @@ -593,7 +628,6 @@ export const [ realm.pubKey('addImportVisitor', MdastFormattingVisitor) realm.pubKey('addImportVisitor', MdastInlineCodeVisitor) realm.pubKey('addImportVisitor', MdastBreakVisitor) - realm.pubKey('addImportVisitor', MdastHTMLVisitor) // basic lexical nodes realm.pubKey('addLexicalNode', ParagraphNode) @@ -608,5 +642,13 @@ export const [ realm.pubKey('addExportVisitor', LexicalGenericHTMLVisitor) realm.pubKey('addComposerChild', SharedHistoryPlugin) + + // Use the JSX extension to parse HTML + if (!params.suppressHtmlProcessing) { + realm.pubKey('addMdastExtension', mdxJsxFromMarkdown()) + realm.pubKey('addSyntaxExtension', mdxJsx()) + realm.pubKey('addToMarkdownExtension', mdxJsxToMarkdown()) + realm.pubKey('addImportVisitor', MdastHTMLVisitor) + } } }) diff --git a/src/plugins/diff-source/DiffSourceWrapper.tsx b/src/plugins/diff-source/DiffSourceWrapper.tsx index 3d82fd8d..37a92847 100644 --- a/src/plugins/diff-source/DiffSourceWrapper.tsx +++ b/src/plugins/diff-source/DiffSourceWrapper.tsx @@ -2,13 +2,22 @@ import React from 'react' import { diffSourcePluginHooks } from '.' import { DiffViewer } from './DiffViewer' import { SourceEditor } from './SourceEditor' +import { corePluginHooks } from '../core' +import styles from '../../styles/ui.module.css' export const DiffSourceWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [error] = corePluginHooks.useEmitterValues('markdownProcessingError') const [viewMode] = diffSourcePluginHooks.useEmitterValues('viewMode') // keep the RTE always mounted, otherwise the state is lost return (
-
{children}
+ {error ? ( +
+

{error.error}.

+

You can fix the errors in source mode and switch to rich text mode when you are ready.

+
+ ) : null} +
{children}
{viewMode === 'diff' ? : null} {viewMode === 'source' ? : null}
diff --git a/src/styles/globals.css b/src/styles/globals.css index 0c52ef03..a5bbc9c4 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -19,6 +19,10 @@ padding: 0 !important; } + & .cm-focused { + outline: none; + } + & .sp-wrapper { border: 1px solid var(--baseLine); border-radius: var(--radius-medium); diff --git a/src/styles/ui.module.css b/src/styles/ui.module.css index 6ec15d76..61b64b73 100644 --- a/src/styles/ui.module.css +++ b/src/styles/ui.module.css @@ -77,6 +77,8 @@ --admonitionNoteBg: var(--slate4); --admonitionNoteBorder: var(--slate8); + --error-color: var(--red10); + --spacing-0:0px; --spacing-px:1px; --spacing-0_5:0.125rem; @@ -1113,4 +1115,13 @@ form.multiFieldForm { } } +.markdownParseError { + border-radius: var(--radius-base); + border: 1px solid var(--error-color); + padding: var(--spacing-2); + margin-block: var(--spacing-2); + color: var(--error-color); + font-size: var(--text-xs); +} +