diff --git a/packages/git-sync/src/lib/markdown-converter.ts b/packages/git-sync/src/lib/markdown-converter.ts index d75bccb7..b81692ea 100644 --- a/packages/git-sync/src/lib/markdown-converter.ts +++ b/packages/git-sync/src/lib/markdown-converter.ts @@ -440,10 +440,21 @@ export function convertProseMirrorToMarkdown(content: any): string { .replace(/\|/g, "\\|"); } - case "callout": - const calloutType = node.attrs?.type || "info"; - const calloutContent = nodeContent.map(processNode).join("\n"); - return `:::${calloutType.toLowerCase()}\n${calloutContent}\n:::`; + case "callout": { + // Obsidian-native callout: `> [!type]` opener + a blockquote (`>`-prefixed) + // body, so it renders as a callout in Obsidian. The importer parses both + // this and the legacy `:::type` fence (existing vaults). Each body line is + // blockquote-prefixed; a blank line becomes a bare `>` so the callout is + // not split. + const calloutType = (node.attrs?.type || "info").toLowerCase(); + const calloutBody = nodeContent + .map(processNode) + .join("\n") + .split("\n") + .map((l: string) => (l.length ? `> ${l}` : ">")) + .join("\n"); + return `> [!${calloutType}]\n${calloutBody}`; + } case "details": { // The `open` (collapsed/expanded) state lives on the details node, NOT on diff --git a/packages/git-sync/src/lib/markdown-to-prosemirror.ts b/packages/git-sync/src/lib/markdown-to-prosemirror.ts index 60919fba..4466edf4 100644 --- a/packages/git-sync/src/lib/markdown-to-prosemirror.ts +++ b/packages/git-sync/src/lib/markdown-to-prosemirror.ts @@ -32,6 +32,14 @@ const MAX_CALLOUT_PREPROCESS_BYTES = 4 * 1024 * 1024; // 4 MB const CALLOUT_OPEN_RE = /^:::\s*(\w+)\s*$/; /** Matches a bare closing callout fence: `:::`. */ const CALLOUT_CLOSE_RE = /^:::\s*$/; +/** + * Matches an Obsidian-native callout opener: `> [!type]` (type captured). An + * optional title after the type is allowed but ignored (the Docmost callout + * schema has no title). The body is the following contiguous blockquote lines. + */ +const CALLOUT_BQ_OPEN_RE = /^>\s*\[!(\w+)\]/; +/** Matches any blockquote continuation line (`>` … ). */ +const BLOCKQUOTE_LINE_RE = /^>/; /** Matches the start/end of a code fence (``` or ~~~), capturing the marker. */ const CODE_FENCE_RE = /^(\s*)(`{3,}|~{3,})/; @@ -156,6 +164,29 @@ async function preprocessCallouts(markdown: string): Promise { continue; } + // An Obsidian-native callout: `> [!type]` opener; the body is the following + // CONTIGUOUS blockquote (`>`-prefixed) lines. Strip ONE blockquote level and + // recurse so nested callouts (`> > [!type]`) are handled, then emit the same + // callout div the `:::` path produces. A normal blockquote (no `[!type]` on + // its first line) does not match and stays a blockquote. + const bqOpen = line.match(CALLOUT_BQ_OPEN_RE); + if (bqOpen) { + const type = bqOpen[1].toLowerCase(); + const bodyLines: string[] = []; + let j = i + 1; + for (; j < lines.length; j++) { + if (!BLOCKQUOTE_LINE_RE.test(lines[j])) break; + bodyLines.push(lines[j].replace(/^>\s?/, "")); + } + const inner = await transform(bodyLines); + const renderedInner = await marked.parse(inner); + out.push( + `\n
${renderedInner}
\n`, + ); + i = j; + continue; + } + out.push(line); i++; } diff --git a/packages/git-sync/test/markdown-converter.test.ts b/packages/git-sync/test/markdown-converter.test.ts index 1461d19a..98cb88c0 100644 --- a/packages/git-sync/test/markdown-converter.test.ts +++ b/packages/git-sync/test/markdown-converter.test.ts @@ -363,14 +363,14 @@ describe('convertProseMirrorToMarkdown', () => { content: [para(text('beware'))], }), ); - expect(out).toBe(':::warning\nbeware\n:::'); + expect(out).toBe('> [!warning]\n> beware'); }); it('callout defaults to info', () => { const out = convertProseMirrorToMarkdown( doc({ type: 'callout', content: [para(text('hi'))] }), ); - expect(out).toBe(':::info\nhi\n:::'); + expect(out).toBe('> [!info]\n> hi'); }); it('details emits summary + content wrapped in
', () => { @@ -585,20 +585,16 @@ describe('convertProseMirrorToMarkdown', () => { }), ); // NOTE(review): the spec predicted ':::warning\nline1\n\nline2\n:::' (a - // blank line between paragraphs, attributed to "marked's paragraph - // blank-line"). The real converter does NOT route callout bodies through - // marked — the callout case (src 374-376) joins its rendered children - // with a single '\n' (calloutContent = nodeContent.map(processNode) - // .join('\n')), and each paragraph renders to just its text. So the ACTUAL - // (and correct-per-source) body is 'line1\nline2' with ONE newline. We - // still pin the two behaviors the spec cares about: the .toLowerCase() + // The converter joins the callout's rendered children with a single '\n' + // and emits an Obsidian-native callout: a `> [!type]` opener plus one + // `>`-prefixed body line per content line. We pin the lowercasing // (WARNING -> warning) and the multi-child join. - expect(out).toBe(':::warning\nline1\nline2\n:::'); - // The fence type is lowercased (regression to ':::WARNING' breaks import). - expect(out.startsWith(':::warning\n')).toBe(true); - expect(out).not.toContain(':::WARNING'); - // Both paragraph children are present and joined inside the fence. - expect(out).toContain('line1\nline2'); + expect(out).toBe('> [!warning]\n> line1\n> line2'); + // The type is lowercased (an uppercase `[!WARNING]` would not re-import). + expect(out.startsWith('> [!warning]\n')).toBe(true); + expect(out).not.toContain('[!WARNING]'); + // Both paragraph children are present, each blockquote-prefixed. + expect(out).toContain('> line1\n> line2'); }); // Spec 4 — blockquote per-line prefixer over a multi-line nested callout. @@ -617,13 +613,11 @@ describe('convertProseMirrorToMarkdown', () => { ); // NOTE(review): the spec predicted '> :::info\n> a\n>\n> b\n> :::', // assuming the nested callout body contains a blank line between 'a' and - // 'b' (which would exercise the line.length?'> ':'>' empty-line branch). - // But per Spec 3's finding the callout joins paragraphs with a SINGLE - // '\n', so its rendered output ':::info\na\nb\n:::' has NO blank line. - // The blockquote prefixer (src 214-221) therefore prefixes each of the - // four non-empty lines with '> ', yielding the ACTUAL output below — the - // realistic per-line-prefix loop over a multi-line nested-block child. - expect(out).toBe('> :::info\n> a\n> b\n> :::'); + // The nested callout renders as an Obsidian callout '> [!info]\n> a\n> b' + // (single-'\n' join, no blank line). The outer blockquote prefixer then + // prefixes each of those lines with '> ' again, yielding a doubly-nested + // blockquote — the realistic per-line-prefix loop over a multi-line child. + expect(out).toBe('> > [!info]\n> > a\n> > b'); // Every produced line carries the '> ' prefix (no line escapes to col 0). for (const line of out.split('\n')) { expect(line.startsWith('>')).toBe(true); diff --git a/packages/git-sync/test/markdown-to-prosemirror-gaps.test.ts b/packages/git-sync/test/markdown-to-prosemirror-gaps.test.ts index a630aa91..b758dfd5 100644 --- a/packages/git-sync/test/markdown-to-prosemirror-gaps.test.ts +++ b/packages/git-sync/test/markdown-to-prosemirror-gaps.test.ts @@ -23,6 +23,63 @@ const allText = (node: any): string => { return (node?.content || []).map(allText).join(''); }; +// --------------------------------------------------------------------------- +// Obsidian-native callouts: the export emits `> [!type]` (a blockquote callout, +// which renders as a callout in Obsidian) and the importer parses it back — +// alongside the legacy `:::type` fence so existing vaults keep working. +// --------------------------------------------------------------------------- +describe('preprocessCallouts: Obsidian `> [!type]` callouts', () => { + it('imports `> [!type]` as a callout node (not a plain blockquote)', async () => { + const md = ['> [!warning]', '> be careful', '> second line'].join('\n'); + const docNode = await markdownToProseMirror(md); + const callouts = findAll(docNode, 'callout'); + expect(callouts).toHaveLength(1); + expect(callouts[0].attrs?.type).toBe('warning'); + expect(findAll(docNode, 'blockquote')).toHaveLength(0); + expect(allText(callouts[0])).toContain('be careful'); + }); + + it('imports a nested `> > [!type]` callout inside another', async () => { + const md = ['> [!info]', '> outer', '> > [!danger]', '> > inner'].join('\n'); + const docNode = await markdownToProseMirror(md); + const outer = docNode.content?.[0]; + expect(outer?.type).toBe('callout'); + expect(outer?.attrs?.type).toBe('info'); + const inner = (outer?.content || []).filter( + (n: any) => n.type === 'callout', + ); + expect(inner).toHaveLength(1); + expect(inner[0].attrs?.type).toBe('danger'); + expect(allText(inner[0])).toContain('inner'); + }); + + it('round-trips a callout: export -> `> [!type]` -> import keeps type + body', async () => { + const original = { + type: 'doc', + content: [ + { + type: 'callout', + attrs: { type: 'success' }, + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'done' }] }], + }, + ], + }; + const md = convertProseMirrorToMarkdown(original); + expect(md).toBe('> [!success]\n> done'); + const back = await markdownToProseMirror(md); + const callouts = findAll(back, 'callout'); + expect(callouts).toHaveLength(1); + expect(callouts[0].attrs?.type).toBe('success'); + expect(allText(callouts[0])).toContain('done'); + }); + + it('a plain blockquote (no `[!type]`) stays a blockquote', async () => { + const back = await markdownToProseMirror('> just a quote\n> more'); + expect(findAll(back, 'callout')).toHaveLength(0); + expect(findAll(back, 'blockquote')).toHaveLength(1); + }); +}); + // --------------------------------------------------------------------------- // 3. preprocessCallouts — two uncovered branches. //