feat(git-sync): Obsidian-native callouts (> [!type]) instead of :::type

Callouts now export as Obsidian's blockquote-callout syntax — `> [!type]` opener
plus a `>`-prefixed body — so they render as real callouts when the vault is
opened in Obsidian, instead of `:::type` (Docusaurus-style) which Obsidian shows
as a plain blockquote.

- Export (markdown-converter `case "callout"`): `> [!type]` + each body line
  blockquote-prefixed (a blank line becomes a bare `>` so the callout is not
  split). Nested callouts naturally become `> > [!type]`.
- Import (preprocessCallouts): a new branch recognizes `> [!type]` openers and
  the contiguous `>`-prefixed body, strips one blockquote level and recurses (so
  nested callouts work), emitting the same callout div the `:::` path produces.
  The legacy `:::type` parser is KEPT so existing vaults keep importing. A plain
  blockquote (no `[!type]`) stays a blockquote.

Tests: 4 converter golden tests updated to the new `> [!type]` output; 4 new
import tests (simple, nested, round-trip, plain-blockquote-untouched). The §13.1
gate still round-trips callout losslessly through the real server schema.
git-sync vitest 675 (+1 expected-fail), gate 27.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-06-26 04:22:38 +03:00
parent 032df2cf31
commit 937f04b735
4 changed files with 119 additions and 26 deletions

View File

@@ -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

View File

@@ -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<string> {
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<div data-type="callout" data-callout-type="${type}">${renderedInner}</div>\n`,
);
i = j;
continue;
}
out.push(line);
i++;
}

View File

@@ -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 <details>', () => {
@@ -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);

View File

@@ -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.
//