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"
+ );
+});