Skip to content

Commit 1dae705

Browse files
Merge pull request #120 from conorbronsdon/fix/export-editor-defaults
Treat the editor's default textAlign: null as lossless in draft export
2 parents 10bfc8b + e75ee1f commit 1dae705

3 files changed

Lines changed: 35 additions & 3 deletions

File tree

docs/export.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ API body remains null; a malformed JSON body remains its original string.
4141
| `unavailable` | Markdown could not be produced; the original source and a diagnostic remain available |
4242

4343
`unsupported_nodes` entries name a JSON-pointer-style path, node/mark type and
44-
reason. Unknown widgets and embeds get explicit Markdown placeholders. Their
44+
reason. The editor stores `textAlign: null` (default alignment) on paragraphs and
45+
headings; that value is lossless and not reported, while any other alignment is. Unknown widgets and embeds get explicit Markdown placeholders. Their
4546
content stays in the original body rather than being presented as a complete
4647
flattened export. Unsupported marks retain their text and report omitted
4748
formatting. Native image dimensions/layout attributes are reported; alt text,

src/__tests__/draft-export.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,35 @@ describe("loss-aware reverse Markdown conversion", () => {
127127
expect(reverse([{ type: "paywall" }, { type: "paywall" }]).unsupported_nodes).toContainEqual(expect.objectContaining({ type: "paywall" }));
128128
expect(reverse([{ type: "ordered_list", attrs: { order: 1_000_000_000 }, content: [{ type: "list_item", content: [paragraph(text("A"))] }] }]).status).toBe("partial");
129129
});
130+
it("treats the editor's default textAlign: null as lossless and still reports real alignment", () => {
131+
const aligned = (align: unknown) => ({ attrs: { textAlign: align } });
132+
const editorDoc = [
133+
{ type: "heading", attrs: { level: 2, textAlign: null }, content: [text("Title")] },
134+
{ ...paragraph(text("Body "), text("bold", [{ type: "bold" }])), ...aligned(null) },
135+
{ type: "blockquote", content: [{ ...paragraph(text("Quoted")), ...aligned(null) }] },
136+
];
137+
const result = reverse(editorDoc);
138+
expect(result.status).toBe("converted");
139+
expect(result.unsupported_nodes).toEqual([]);
140+
expect(result.markdown).toBe("## Title\n\nBody **bold**\n\n> Quoted\n");
141+
for (const align of ["center", "right", ""]) {
142+
const partial = reverse([{ ...paragraph(text("x")), ...aligned(align) }]);
143+
expect(partial.status).toBe("partial");
144+
expect(partial.unsupported_nodes).toContainEqual(expect.objectContaining({ path: "/content/0/attrs", type: "paragraph" }));
145+
}
146+
expect(reverse([{ type: "heading", attrs: { level: 2, textAlign: "center" }, content: [text("T")] }]).status).toBe("partial");
147+
expect(reverse([{ ...paragraph(text("x")), attrs: { textAlign: null, indent: 1 } }]).status).toBe("partial");
148+
});
149+
150+
it("exports a body captured from the editor, with textAlign: null, as converted", () => {
151+
const fixture = JSON.parse(readFileSync(new URL("./fixtures/footnotes-editor.json", import.meta.url), "utf8"));
152+
const withEditorDefaults = JSON.parse(JSON.stringify(fixture.document), (key, value) =>
153+
value && typeof value === "object" && value.type === "paragraph" ? { ...value, attrs: { textAlign: null } } : value);
154+
const result = prosemirrorToMarkdown(JSON.stringify(withEditorDefaults));
155+
expect(result.status).toBe("converted");
156+
expect(convertMarkdown(result.markdown!).document).toEqual(fixture.document);
157+
});
158+
130159
it("exports editor footnotes and reimports the identical structure", () => {
131160
const fixture = JSON.parse(readFileSync(new URL("./fixtures/footnotes-editor.json", import.meta.url), "utf8"));
132161
const result = prosemirrorToMarkdown(JSON.stringify(fixture.document));

src/utils/prosemirror-to-markdown.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ const kind = (n: Node) => Object.hasOwn(aliases, n.type) ? aliases[n.type] : n.t
2222
const children = (n: Node): unknown[] => Array.isArray(n.content) ? n.content : [];
2323
const attrs = (n: Node): Record<string, unknown> => object(n.attrs) ? n.attrs : {};
2424
const literal = (value: string): PhrasingContent => ({ type: "text", value });
25+
// The editor stores textAlign: null (default alignment) on paragraphs and headings; only that value maps losslessly.
26+
const defaultAlignment = (a: Record<string, unknown>): string[] => Object.hasOwn(a, "textAlign") && a.textAlign === null ? ["textAlign"] : [];
2527
const safeUrl = (value: unknown, image = false): value is string => {
2628
if (typeof value !== "string" || /[\u0000-\u0020\u007f]/.test(value)) return false;
2729
try {
@@ -175,11 +177,11 @@ export function prosemirrorToMarkdown(source: string): MarkdownExport {
175177
if (Array.isArray(value.marks) && value.marks.length) report(at + "/marks", value.type, "Block marks are retained only in source_prosemirror.");
176178
switch (type) {
177179
case "paragraph":
178-
check(value, at);
180+
check(value, at, defaultAlignment(a));
179181
if (!content.length) report(at, type, "An empty paragraph has no distinct Markdown representation.");
180182
return [{ type: "paragraph", children: inline(content, at + "/content") }];
181183
case "heading":
182-
check(value, at, ["level"]);
184+
check(value, at, ["level", ...defaultAlignment(a)]);
183185
if (![1, 2, 3, 4, 5, 6].includes(Number(a.level)) || typeof a.level !== "number") return placeholder(at, type, "Invalid heading level retained in original source.");
184186
return [{ type: "heading", depth: a.level as 1 | 2 | 3 | 4 | 5 | 6, children: inline(content, at + "/content") }];
185187
case "blockquote":

0 commit comments

Comments
 (0)