= {
+ note: '',
+ abstract: '',
+ info: '',
+ todo: '',
+ tip: '',
+ success: '',
+ question: '',
+ warning: '',
+ failure: '',
+ danger: '',
+ bug: '',
+ example: '',
+ quote: '',
+ custom: '',
+};
+
+/** Full SVG icon string for a callout type, at the given pixel size. */
+export function calloutIcon(type: string, size = 18): string {
+ const inner = ICONS[calloutGroup(type)] || ICONS.note;
+ return ``;
+}
+
+/** Ordered list of types offered in the type picker. */
+export const CALLOUT_MENU: { type: string; label: string }[] = [
+ { type: 'note', label: 'Note' },
+ { type: 'abstract', label: 'Abstract' },
+ { type: 'info', label: 'Info' },
+ { type: 'todo', label: 'Todo' },
+ { type: 'tip', label: 'Tip' },
+ { type: 'success', label: 'Success' },
+ { type: 'question', label: 'Question' },
+ { type: 'warning', label: 'Warning' },
+ { type: 'failure', label: 'Failure' },
+ { type: 'danger', label: 'Danger' },
+ { type: 'bug', label: 'Bug' },
+ { type: 'example', label: 'Example' },
+ { type: 'quote', label: 'Quote' },
+];
+
+// First line of a callout: [!type], optional +/- fold marker, optional title.
+const CALLOUT_RE = /^\s*\[!([\w-]+)\]([-+]?)[ \t]*(.*)$/;
+
+const BLOCK_SELECTOR =
+ 'p,ul,ol,blockquote,pre,h1,h2,h3,h4,h5,h6,table,hr,div[data-callout],div[data-secret-block],div[data-math-block],div[data-pdf-src],div[data-page-break]';
+
+/**
+ * Convert callout blockquotes produced by markdown-it into
+ * `` blocks that the Callout node parses. Operates in
+ * place on a detached container element. Processes depth-first so nested
+ * callouts (`> > [!todo]`) are converted before their parents.
+ */
+export function transformCalloutBlockquotes(root: Element): void {
+ const walk = (el: Element) => {
+ for (const child of Array.from(el.children)) walk(child);
+ if (el.tagName === 'BLOCKQUOTE') convertBlockquote(el);
+ };
+ walk(root);
+}
+
+function convertBlockquote(bq: Element): void {
+ const first = bq.firstElementChild;
+ if (!first || first.tagName !== 'P') return;
+
+ // markdown-it merges the title line and any immediately-following body lines
+ // into one paragraph (separated by "\n"). Split off the first line.
+ const innerHtml = (first as HTMLElement).innerHTML;
+ const nlIdx = innerHtml.indexOf('\n');
+ const firstLineHtml = nlIdx === -1 ? innerHtml : innerHtml.slice(0, nlIdx);
+ const restHtml = nlIdx === -1 ? '' : innerHtml.slice(nlIdx + 1);
+
+ const probe = document.createElement('div');
+ probe.innerHTML = firstLineHtml;
+ const m = (probe.textContent || '').match(CALLOUT_RE);
+ if (!m) return;
+
+ const type = m[1].toLowerCase();
+ const foldChar = m[2];
+ const title = (m[3] || '').trim();
+ const foldable = foldChar === '+' || foldChar === '-';
+ const folded = foldChar === '-';
+
+ const div = document.createElement('div');
+ div.setAttribute('data-callout', type);
+ div.setAttribute('data-callout-foldable', foldable ? 'true' : 'false');
+ div.setAttribute('data-callout-folded', folded ? 'true' : 'false');
+ if (title) div.setAttribute('data-callout-title', title);
+
+ if (restHtml.trim()) {
+ (first as HTMLElement).innerHTML = restHtml;
+ div.appendChild(first);
+ } else {
+ first.remove();
+ }
+ while (bq.firstChild) div.appendChild(bq.firstChild);
+
+ // content is `block+`; guarantee at least one block.
+ if (!div.querySelector(BLOCK_SELECTOR)) div.appendChild(document.createElement('p'));
+
+ bq.replaceWith(div);
+}
+
+/**
+ * Serialize a Callout ProseMirror node back to `> [!type] ...` markdown.
+ * `serializeChild` serializes a child block (the editor's own serializer),
+ * which makes nesting fall out automatically (each `>` line gains another `>`).
+ */
+export function serializeCallout(node: any, serializeChild: (child: any) => string): string {
+ const type = node.attrs.type || 'note';
+ const foldable = !!node.attrs.foldable;
+ const folded = !!node.attrs.folded;
+ const title = (node.attrs.title || '').trim();
+ const suffix = foldable ? (folded ? '-' : '+') : '';
+ const header = `[!${type}]${suffix}${title ? ' ' + title : ''}`;
+
+ const parts: string[] = [];
+ node.forEach((child: any) => parts.push(serializeChild(child).replace(/\n+$/, '')));
+ while (parts.length && parts[parts.length - 1] === '') parts.pop();
+
+ const out = [`> ${header}`];
+ const inner = parts.join('\n');
+ if (inner.length) {
+ for (const line of inner.split('\n')) out.push(line.length ? `> ${line}` : '>');
+ }
+ return out.join('\n') + '\n';
+}