import type { MarkdownIt } from "markdown-it"; // The `-`/`+` after the type is Obsidian's fold marker: `-` starts collapsed, `+` starts // open, absent means not foldable at all. Without this group it lands in the title. const CALLOUT_RE = /^\[!(\w+)\]([-+])?\s*(.*)?$/; const TYPE_ALIASES: Record = { info: "note", hint: "tip", caution: "warning", bug: "danger", cite: "quote", summary: "abstract", tldr: "abstract", check: "success", done: "success", help: "question", faq: "question", fail: "failure", missing: "failure", }; function normalizeType(raw: string): string { const lower = raw.toLowerCase(); return TYPE_ALIASES[lower] ?? lower; } export function calloutPlugin(mdi: MarkdownIt): void { mdi.core.ruler.after("block", "callout", (state) => { const tokens = state.tokens; for (let i = 0; i < tokens.length; i++) { const open = tokens[i]; if (open?.type !== "blockquote_open") continue; const paraOpen = tokens[i + 1]; const inline = tokens[i + 2]; if (paraOpen?.type !== "paragraph_open") continue; if (inline?.type !== "inline") continue; const lines = inline.content.split("\n"); const firstLine = lines[0] ?? ""; const match = CALLOUT_RE.exec(firstLine); if (!match) continue; const type = normalizeType(match[1] ?? ""); const fold = match[2] ?? ""; const customTitle = match[3]?.trim() || null; const title = customTitle || type.charAt(0).toUpperCase() + type.slice(1); open.type = "callout_open"; open.tag = fold ? "details" : "div"; open.info = fold; open.attrSet("class", `callout callout-${type}`); const titleOpen = new state.Token( "callout_title_open", fold ? "summary" : "p", 1, ); titleOpen.info = fold; titleOpen.attrSet("class", "callout-title"); const titleInline = new state.Token("inline", "", 0); titleInline.content = title; titleInline.children = []; const titleClose = new state.Token( "callout_title_close", fold ? "summary" : "p", -1, ); titleClose.info = fold; lines.shift(); const remaining = lines.join("\n").trim(); if (remaining) { inline.content = remaining; tokens.splice(i + 1, 0, titleOpen, titleInline, titleClose); } else { const paraClose = tokens[i + 3]; if (paraClose?.type === "paragraph_close") { tokens.splice(i + 1, 3, titleOpen, titleInline, titleClose); } else { inline.content = remaining; tokens.splice(i + 1, 0, titleOpen, titleInline, titleClose); } } let depth = 1; for (let j = i + 1; j < tokens.length; j++) { if (tokens[j]?.type === "blockquote_open") depth++; if (tokens[j]?.type === "callout_open") depth++; if ( tokens[j]?.type === "blockquote_close" || tokens[j]?.type === "callout_close" ) { depth--; if (depth === 0) { const closeToken = tokens[j]; if (closeToken) { closeToken.type = "callout_close"; closeToken.tag = fold ? "details" : "div"; closeToken.info = fold; } break; } } } } }); mdi.renderer.rules["callout_open"] = (tokens, idx) => { const token = tokens[idx]; const cls = token?.attrGet("class") ?? "callout"; if (!token?.info) return `
\n`; return `
\n`; }; mdi.renderer.rules["callout_close"] = (tokens, idx) => tokens[idx]?.info ? "
\n" : "
\n"; mdi.renderer.rules["callout_title_open"] = (tokens, idx) => { const token = tokens[idx]; const cls = token?.attrGet("class") ?? "callout-title"; if (!token?.info) return `

`; return `

`; }; mdi.renderer.rules["callout_title_close"] = (tokens, idx) => tokens[idx]?.info ? "\n" : "

\n"; }