From f329b56d0edbbd687728c7436713f2c99e8ec722 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Sat, 6 Jun 2026 07:24:16 -0400 Subject: [PATCH] fix: Hard break serialization for commonMark (#12603) * fix: Hard break serialization for commonMark * tests --- .../extensions/ClipboardTextSerializer.ts | 2 +- app/models/helpers/ProsemirrorHelper.ts | 2 +- shared/editor/lib/markdown/serializer.ts | 13 ++- shared/editor/nodes/HardBreak.ts | 4 +- shared/editor/nodes/Paragraph.ts | 2 +- .../editor/rules}/checkboxes.test.ts | 8 +- shared/editor/rules/hardbreaks.test.ts | 88 +++++++++++++++++++ 7 files changed, 113 insertions(+), 6 deletions(-) rename {server/editor => shared/editor/rules}/checkboxes.test.ts (90%) create mode 100644 shared/editor/rules/hardbreaks.test.ts diff --git a/app/editor/extensions/ClipboardTextSerializer.ts b/app/editor/extensions/ClipboardTextSerializer.ts index b5a05a7f70..c1146c87fa 100644 --- a/app/editor/extensions/ClipboardTextSerializer.ts +++ b/app/editor/extensions/ClipboardTextSerializer.ts @@ -61,7 +61,7 @@ export default class ClipboardTextSerializer extends Extension { .map((node) => ProsemirrorHelper.toPlainText(node)) .join("\n") : mdSerializer.serialize(slice.content, { - softBreak: true, + commonMark: true, }); }, }, diff --git a/app/models/helpers/ProsemirrorHelper.ts b/app/models/helpers/ProsemirrorHelper.ts index c48534e231..8e268fd051 100644 --- a/app/models/helpers/ProsemirrorHelper.ts +++ b/app/models/helpers/ProsemirrorHelper.ts @@ -29,7 +29,7 @@ export class ProsemirrorHelper { ); const markdown = serializer.serialize(doc, { - softBreak: true, + commonMark: true, }); return markdown; }; diff --git a/shared/editor/lib/markdown/serializer.ts b/shared/editor/lib/markdown/serializer.ts index 85a1d82ad3..5eec8aba7a 100644 --- a/shared/editor/lib/markdown/serializer.ts +++ b/shared/editor/lib/markdown/serializer.ts @@ -3,7 +3,18 @@ // https://raw.githubusercontent.com/ProseMirror/prosemirror-markdown/master/src/to_markdown.js // forked for table support -type Options = { tightLists?: boolean; softBreak?: boolean }; +/** Options that control how a ProseMirror document is serialized to Markdown. */ +type Options = { + /** Whether list items are rendered without blank lines between them. */ + tightLists?: boolean; + /** + * Whether to emit portable, standard CommonMark intended to leave Outline, + * such as when copying to the clipboard or exporting. When false the + * serializer uses Outline's internal escaped representation, which round-trips + * losslessly through its own parser but is not standard Markdown. + */ + commonMark?: boolean; +}; // ::- A specification for serializing a ProseMirror document as // Markdown/CommonMark text. diff --git a/shared/editor/nodes/HardBreak.ts b/shared/editor/nodes/HardBreak.ts index 2ce830c0c1..34292759e1 100644 --- a/shared/editor/nodes/HardBreak.ts +++ b/shared/editor/nodes/HardBreak.ts @@ -55,8 +55,10 @@ export default class HardBreak extends Node { } toMarkdown(state: MarkdownSerializerState) { + // Two trailing spaces is a CommonMark hard break that survives a + // copy/export round-trip, unlike a bare newline. state.write( - state.inTable ? "
" : state.options.softBreak ? "\n" : "\\n" + state.inTable ? "
" : state.options.commonMark ? " \n" : "\\n" ); } diff --git a/shared/editor/nodes/Paragraph.ts b/shared/editor/nodes/Paragraph.ts index d458cea4ee..6b91cee92d 100644 --- a/shared/editor/nodes/Paragraph.ts +++ b/shared/editor/nodes/Paragraph.ts @@ -58,7 +58,7 @@ export default class Paragraph extends Node { node.childCount === 0 && !state.inTable ) { - state.write(state.options.softBreak ? "\n" : "\\\n"); + state.write(state.options.commonMark ? "\n" : "\\\n"); } else { state.renderInline(node); state.closeBlock(node); diff --git a/server/editor/checkboxes.test.ts b/shared/editor/rules/checkboxes.test.ts similarity index 90% rename from server/editor/checkboxes.test.ts rename to shared/editor/rules/checkboxes.test.ts index 3a31ff25e2..c9db14d8da 100644 --- a/server/editor/checkboxes.test.ts +++ b/shared/editor/rules/checkboxes.test.ts @@ -1,4 +1,10 @@ -import { parser, serializer } from "@server/editor"; +import { extensionManager, schema } from "../../test/editor"; + +const serializer = extensionManager.serializer(); +const parser = extensionManager.parser({ + schema, + plugins: extensionManager.rulePlugins, +}); interface ProsemirrorNode { type: string; diff --git a/shared/editor/rules/hardbreaks.test.ts b/shared/editor/rules/hardbreaks.test.ts new file mode 100644 index 0000000000..0c4984c6de --- /dev/null +++ b/shared/editor/rules/hardbreaks.test.ts @@ -0,0 +1,88 @@ +import { extensionManager, schema } from "../../test/editor"; + +const serializer = extensionManager.serializer(); +const parser = extensionManager.parser({ + schema, + plugins: extensionManager.rulePlugins, +}); + +const docWithHardBreak = schema.nodeFromJSON({ + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Line one" }, + { type: "br" }, + { type: "text", text: "Line two" }, + ], + }, + ], +}); + +it("parses two trailing spaces as a hard break", () => { + const ast = parser.parse("Line one \nLine two"); + + expect(ast?.toJSON()).toEqual(docWithHardBreak.toJSON()); +}); + +it("parses a backslash line ending as a hard break", () => { + const ast = parser.parse("Line one\\\nLine two"); + + expect(ast?.toJSON()).toEqual(docWithHardBreak.toJSON()); +}); + +it("serializes hard breaks as a CommonMark break when commonMark is set", () => { + // The commonMark option is used when copying to the clipboard and exporting + // documents – two trailing spaces are a standard Markdown hard break that + // renders in external viewers and parses back into a `br`, unlike a bare + // newline. + expect(serializer.serialize(docWithHardBreak, { commonMark: true })).toBe( + "Line one \nLine two" + ); +}); + +it("round-trips hard breaks through the copy/export serializer", () => { + let node = docWithHardBreak; + + for (let i = 0; i < 3; i++) { + const markdown = serializer.serialize(node, { commonMark: true }); + node = parser.parse(markdown)!; + expect(node.toJSON()).toEqual(docWithHardBreak.toJSON()); + } +}); + +it("serializes hard breaks inside tables as a literal break tag", () => { + const docWithTable = schema.nodeFromJSON({ + type: "doc", + content: [ + { + type: "table", + content: [ + { + type: "tr", + content: [ + { + type: "td", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "Line one" }, + { type: "br" }, + { type: "text", text: "Line two" }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }); + + expect(serializer.serialize(docWithTable, { commonMark: true })).toContain( + "Line one
Line two" + ); +});