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:
@@ -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
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user