diff --git a/apps/roam/src/utils/__tests__/pageToMarkdown.test.ts b/apps/roam/src/utils/__tests__/pageToMarkdown.test.ts new file mode 100644 index 000000000..8991fb3b5 --- /dev/null +++ b/apps/roam/src/utils/__tests__/pageToMarkdown.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vitest"; +import type { TreeNode, ViewType } from "roamjs-components/types"; + +vi.mock("roamjs-components/queries/getFullTreeByParentUid", () => ({ + default: () => ({ children: [] }), +})); + +// Runs before the imports below: getDiscourseNodes calls generateUID at module load. +vi.hoisted(() => { + (globalThis as { window?: unknown }).window = { + roamAlphaAPI: { util: { generateUID: () => "someUid" } }, + }; +}); + +import { toMarkdown } from "~/utils/pageToMarkdown"; + +const block = (text: string, children: TreeNode[] = []): TreeNode => + ({ text, children, order: 0, uid: "" }) as unknown as TreeNode; + +const opts = { + refs: false, + embeds: false, + simplifiedFilename: false, + maxFilenameLength: 64, + allNodes: [], + removeSpecialCharacters: false, + linkType: "wikilinks", +}; + +const render = ({ + c, + v, + flatten, +}: { + c: TreeNode; + v?: ViewType; + flatten?: boolean; +}): string => toMarkdown({ c, v, opts: { ...opts, flatten } }); + +const codeBlock = "```js\nconst a = 1;\n\nconst b = 2;\n```"; + +describe("toMarkdown multi-line blocks", () => { + it("indents every line of a top-level bullet to its content column", () => { + expect(render({ c: block(`intro\n${codeBlock}`) })).toBe( + "- intro\n ```js\n const a = 1;\n\n const b = 2;\n ```", + ); + }); + + it("indents every line of a nested bullet to its content column", () => { + expect(render({ c: block("parent", [block(`intro\n${codeBlock}`)]) })).toBe( + "- parent\n - intro\n ```js\n const a = 1;\n\n const b = 2;\n ```", + ); + }); + + it("indents to the wider content column of a numbered item", () => { + expect(render({ c: block("first\nsecond"), v: "numbered" })).toBe( + "1. first\n second", + ); + }); + + it("leaves continuation lines alone when flattening", () => { + expect( + render({ c: block("parent", [block("first\nsecond")]), flatten: true }), + ).toBe("- parent\n- first\nsecond"); + }); + + it("leaves continuation lines alone in the document view type", () => { + expect( + render({ c: block("parent", [block("first\nsecond")]), v: "document" }), + ).toBe("parent\n\n first\nsecond\n"); + }); +}); diff --git a/apps/roam/src/utils/pageToMarkdown.ts b/apps/roam/src/utils/pageToMarkdown.ts index 7550c77fc..ac43c4839 100644 --- a/apps/roam/src/utils/pageToMarkdown.ts +++ b/apps/roam/src/utils/pageToMarkdown.ts @@ -109,6 +109,20 @@ const handleFrontmatter = ({ return output; }; +const indentContinuationLines = ({ + text, + width, +}: { + text: string; + width: number; +}): string => { + const padding = "".padStart(width, " "); + return text + .split("\n") + .map((line, index) => (index === 0 || !line ? line : `${padding}${line}`)) + .join("\n"); +}; + export const toMarkdown = ({ c, i = 0, @@ -191,6 +205,15 @@ export const toMarkdown = ({ : v; const viewTypePrefix = viewTypeToPrefix[effectiveViewType]; const headingPrefix = c.heading ? `${"".padStart(c.heading, "#")} ` : ""; + // CommonMark ends a list item at a column-0 fence, heading, quote or table, + // so continuation lines must sit at the item's content column. + const blockText = + flatten || effectiveViewType === "document" + ? finalProcessedText + : indentContinuationLines({ + text: finalProcessedText, + width: indentation.length + viewTypePrefix.length, + }); const childrenMarkdown = (c.children || []) .filter((nested) => !!nested.text || !!nested.children?.length) .map((nested) => { @@ -206,7 +229,7 @@ export const toMarkdown = ({ .join(""); const lineBreak = v === "document" ? "\n" : ""; - return `${indentation}${viewTypePrefix}${headingPrefix}${finalProcessedText}${lineBreak}${childrenMarkdown}`; + return `${indentation}${viewTypePrefix}${headingPrefix}${blockText}${lineBreak}${childrenMarkdown}`; }; export const pageToMarkdown = async (