// Obsidian-compatible callouts. // // Callouts are written as blockquotes whose first line is `[!type]`: // // > [!warning] Optional title // > Body markdown... // // A trailing `+` / `-` on the type makes the callout foldable // (`+` = expanded, `-` = collapsed). Types are case-insensitive and have // aliases. Callouts round-trip to this exact syntax so notes stay portable // (no lock-in) and stay compatible with Obsidian. // alias (lowercase) -> canonical group used for icon + colour const ALIASES: Record = { note: 'note', abstract: 'abstract', summary: 'abstract', tldr: 'abstract', info: 'info', todo: 'todo', tip: 'tip', hint: 'tip', important: 'tip', success: 'success', check: 'success', done: 'success', question: 'question', help: 'question', faq: 'question', warning: 'warning', caution: 'warning', attention: 'warning', failure: 'failure', fail: 'failure', missing: 'failure', danger: 'danger', error: 'danger', bug: 'bug', example: 'example', quote: 'quote', cite: 'quote', }; /** * Resolve a (possibly aliased / cased) type to its canonical styling group. * Unknown / user-defined types resolve to 'custom' (neutral accent + tag icon) * so a `[!decision]` reads as intentional rather than masquerading as a note. */ export function calloutGroup(type: string): string { return ALIASES[(type || '').trim().toLowerCase()] || 'custom'; } /** Default header label shown when no custom title is set (matches Obsidian). */ export function calloutLabel(type: string): string { const t = (type || 'note').trim(); return t ? t.charAt(0).toUpperCase() + t.slice(1) : 'Note'; } // Inner SVG markup per canonical group (24x24, stroke = currentColor). const ICONS: Record = { 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 `${inner}`; } /** 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'; }