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:
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user