refactor(plate-editor): replace markdown deserialization with safeDeserializeMarkdown utility

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-05-05 19:10:35 -07:00
parent 5e87a7a251
commit 0654662d29
2 changed files with 79 additions and 13 deletions

View file

@ -11,6 +11,7 @@ import { EditorSaveContext } from "@/components/editor/editor-save-context";
import { CitationKit, injectCitationNodes } from "@/components/editor/plugins/citation-kit";
import { type EditorPreset, presetMap } from "@/components/editor/presets";
import { escapeMdxExpressions } from "@/components/editor/utils/escape-mdx";
import { safeDeserializeMarkdown } from "@/components/editor/utils/safe-deserialize";
import { Editor, EditorContainer } from "@/components/ui/editor";
import { preprocessCitationMarkdown } from "@/lib/citations/citation-parser";
@ -169,15 +170,17 @@ export function PlateEditor({
: markdown
? (editor) => {
if (!enableCitations) {
return editor
.getApi(MarkdownPlugin)
.markdown.deserialize(escapeMdxExpressions(markdown));
return safeDeserializeMarkdown(
editor,
escapeMdxExpressions(markdown)
) as Value;
}
const { content: rewritten, urlMap } = preprocessCitationMarkdown(markdown);
const value = editor
.getApi(MarkdownPlugin)
.markdown.deserialize(escapeMdxExpressions(rewritten));
return injectCitationNodes(value as Descendant[], urlMap) as Value;
const value = safeDeserializeMarkdown(
editor,
escapeMdxExpressions(rewritten)
);
return injectCitationNodes(value, urlMap) as Value;
}
: undefined,
});
@ -200,14 +203,13 @@ export function PlateEditor({
let newValue: Descendant[];
if (enableCitations) {
const { content: rewritten, urlMap } = preprocessCitationMarkdown(markdown);
const deserialized = editor
.getApi(MarkdownPlugin)
.markdown.deserialize(escapeMdxExpressions(rewritten)) as Descendant[];
const deserialized = safeDeserializeMarkdown(
editor,
escapeMdxExpressions(rewritten)
);
newValue = injectCitationNodes(deserialized, urlMap);
} else {
newValue = editor
.getApi(MarkdownPlugin)
.markdown.deserialize(escapeMdxExpressions(markdown)) as Descendant[];
newValue = safeDeserializeMarkdown(editor, escapeMdxExpressions(markdown));
}
editor.tf.reset();
editor.tf.setValue(newValue as Value);

View file

@ -0,0 +1,64 @@
// ---------------------------------------------------------------------------
// Safe markdown deserialization for the Plate editor
// ---------------------------------------------------------------------------
// `remark-mdx` treats any HTML-like tag as JSX, so unbalanced inline HTML
// (very common in GitHub READMEs, web-scraped pages, PDF conversions) makes
// it throw "Expected a closing tag for `<a>`" and crash the editor.
//
// Per the MDX maintainers' guidance (mdx-js/mdx, ipikuka/next-mdx-remote-client
// #14), MDX is the wrong format for untrusted markdown and the recommended
// fix is to fall back to plain markdown parsing. `MarkdownPlugin.deserialize`
// accepts a per-call `remarkPlugins` override, so we can:
//
// 1. Try with `remarkMdx` (rich MDX features, e.g. JSX-style components).
// 2. On failure, retry without `remarkMdx` (lenient HTML, like GitHub).
// 3. As a last resort, render the raw source in a paragraph so the user
// never sees a crashed editor.
// ---------------------------------------------------------------------------
import { MarkdownPlugin, remarkMdx } from "@platejs/markdown";
import type { Descendant } from "platejs";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import type { PlateEditorInstance } from "@/components/editor/plate-editor";
const STRICT_PLUGINS = [remarkGfm, remarkMath, remarkMdx];
const LENIENT_PLUGINS = [remarkGfm, remarkMath];
function plainTextFallback(markdown: string): Descendant[] {
return [
{
type: "p",
children: [{ text: markdown }],
} as unknown as Descendant,
];
}
/**
* Deserialize markdown into a Plate value, gracefully degrading when the
* MDX-strict parser rejects raw HTML. Always returns a renderable value;
* never throws.
*/
export function safeDeserializeMarkdown(
editor: PlateEditorInstance,
markdown: string
): Descendant[] {
const api = editor.getApi(MarkdownPlugin).markdown;
try {
return api.deserialize(markdown, { remarkPlugins: STRICT_PLUGINS }) as Descendant[];
} catch (mdxError) {
if (process.env.NODE_ENV !== "production") {
console.warn(
"[plate-editor] MDX parse failed, retrying without remark-mdx:",
mdxError
);
}
try {
return api.deserialize(markdown, { remarkPlugins: LENIENT_PLUGINS }) as Descendant[];
} catch (fallbackError) {
console.error("[plate-editor] markdown deserialize failed:", fallbackError);
return plainTextFallback(markdown);
}
}
}