Skip to content

Commit

Permalink
feat: improve error handling
Browse files Browse the repository at this point in the history
- Allow optional suppressing of the default JSX handling
- Expose `onError` prop
- Display error message in diff-source mode
  • Loading branch information
petyosi committed Dec 4, 2023
1 parent 15149ee commit fe574f0
Show file tree
Hide file tree
Showing 9 changed files with 231 additions and 36 deletions.
25 changes: 25 additions & 0 deletions docs/error-handling.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 11 additions & 1 deletion src/MDXEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -245,7 +253,9 @@ export const MDXEditor = React.forwardRef<MDXEditorMethods, MDXEditorProps>((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 || [])
]}
Expand Down
13 changes: 13 additions & 0 deletions src/examples/assets/buggy-markdown.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## Job Applications winter 2021

* Macquarie commodities trader <https://www.careers.macquarie.com/en/job/961296/graduate-analyst-commodity-markets-and-finance>
* BBC AI postgrad <https://careerssearch.bbc.co.uk/jobs/job/Artificial-Intelligence-Postgraduate-Apprentice-Scheme-Level-7/57158>
* Canva
* Orsted
* Aldi
* Amazon
* EY
* crossing minds
*
* ibm
* <https://www.workatastartup.com/companies/humanloop/website>
58 changes: 58 additions & 0 deletions src/examples/error-handling.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<MDXEditor
onError={(msg) => console.warn(msg)}
markdown={markdown}
onChange={(md) => console.log('change', { md })}
plugins={ALL_PLUGINS}
/>
)
}

export function MissingPlugins() {
return (
<MDXEditor
onError={(msg) => console.warn(msg)}
markdown={`# Hello`}
onChange={(md) => console.log('change', { md })}
plugins={[
toolbarPlugin({
toolbarContents: () => (
<DiffSourceToggleWrapper>
<ConditionalContents
options={[
{ when: (editor) => editor?.editorType === 'codeblock', contents: () => <ChangeCodeMirrorLanguage /> },
{ when: (editor) => editor?.editorType === 'sandpack', contents: () => <ShowSandpackInfo /> },
{
fallback: () => (
<>
<UndoRedo />
<BoldItalicUnderlineToggles />
</>
)
}
]}
/>
</DiffSourceToggleWrapper>
)
}),
diffSourcePlugin({ viewMode: 'rich-text', diffMarkdown: 'boo' })
]}
/>
)
}
37 changes: 30 additions & 7 deletions src/importMarkdownToLexical.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -105,12 +106,36 @@ export type MdastExtension = NonNullable<MdastExtensions>[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: [] })
Expand Down Expand Up @@ -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({
Expand Down
96 changes: 69 additions & 27 deletions src/plugins/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -107,6 +113,16 @@ export const coreSystem = system((r) => {
const autoFocus = r.node<boolean | { defaultSelection?: 'rootStart' | 'rootEnd'; preventScroll?: boolean }>(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<TextFormatType>()
const currentSelection = r.node<RangeSelection | null>(null)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -233,24 +253,46 @@ export const coreSystem = system((r) => {
const addToMarkdownExtension = createAppendNodeFor(toMarkdownExtensions)
const setMarkdown = r.node<string>()

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)
Expand All @@ -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) {
Expand Down Expand Up @@ -540,7 +573,11 @@ export const coreSystem = system((r) => {
// Events
onBlur,

iconComponentFor
iconComponentFor,

// error handling
markdownProcessingError,
markdownErrorSignal
}
}, [])

Expand All @@ -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<LexicalConvertOptions['toMarkdownOptions']>
readOnly: boolean
iconComponentFor: (name: IconKey) => React.ReactElement
suppressHtmlProcessing?: boolean
}

export const [
Expand All @@ -575,25 +614,20 @@ 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)
realm.pubKey('addImportVisitor', MdastTextVisitor)
realm.pubKey('addImportVisitor', MdastFormattingVisitor)
realm.pubKey('addImportVisitor', MdastInlineCodeVisitor)
realm.pubKey('addImportVisitor', MdastBreakVisitor)
realm.pubKey('addImportVisitor', MdastHTMLVisitor)

// basic lexical nodes
realm.pubKey('addLexicalNode', ParagraphNode)
Expand All @@ -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)
}
}
})
11 changes: 10 additions & 1 deletion src/plugins/diff-source/DiffSourceWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<div style={{ display: viewMode === 'rich-text' ? 'block' : 'none' }}>{children}</div>
{error ? (
<div className={styles.markdownParseError}>
<p>{error.error}.</p>
<p>You can fix the errors in source mode and switch to rich text mode when you are ready.</p>
</div>
) : null}
<div style={{ display: viewMode === 'rich-text' && error == null ? 'block' : 'none' }}>{children}</div>
{viewMode === 'diff' ? <DiffViewer /> : null}
{viewMode === 'source' ? <SourceEditor /> : null}
</div>
Expand Down
Loading

0 comments on commit fe574f0

Please sign in to comment.