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 8e74a9a99d
commit c7ff298831
4 changed files with 119 additions and 26 deletions
@@ -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