From e3b61199bcb1c2408433cc2c699301b53cba8525 Mon Sep 17 00:00:00 2001 From: juprodh Date: Mon, 25 May 2026 16:21:26 +0800 Subject: [PATCH] Add markdown callouts --- public/editor/preview.ts | 2 + public/style.css | 75 ++++++++++++++++++++ src/lib/markdown.ts | 2 + src/lib/markdown/callout-plugin.ts | 108 +++++++++++++++++++++++++++++ tests/lib/markdown.test.ts | 40 +++++++++++ 5 files changed, 227 insertions(+) create mode 100644 src/lib/markdown/callout-plugin.ts diff --git a/public/editor/preview.ts b/public/editor/preview.ts index d3dd543..cad6c29 100644 --- a/public/editor/preview.ts +++ b/public/editor/preview.ts @@ -3,6 +3,7 @@ import MarkdownIt from "markdown-it"; import footnotePlugin from "markdown-it-footnote"; import markPlugin from "markdown-it-mark"; import taskListPlugin from "markdown-it-task-lists"; +import { calloutPlugin } from "../../src/lib/markdown/callout-plugin.ts"; import { commentPlugin } from "../../src/lib/markdown/comment-plugin.ts"; import { katexPlugin } from "../../src/lib/markdown/katex-plugin.ts"; import { wikilinkPlugin } from "../../src/lib/markdown/wikilink-plugin.ts"; @@ -13,6 +14,7 @@ md.use(wikilinkPlugin); md.use(footnotePlugin); md.use(markPlugin); md.use(taskListPlugin, { enabled: false, label: true }); +md.use(calloutPlugin); md.use(katexPlugin, { throwOnError: false }); // Matches the server-side allowlist closely. The preview is for the author's diff --git a/public/style.css b/public/style.css index fe74ab7..7e065a2 100644 --- a/public/style.css +++ b/public/style.css @@ -29,6 +29,81 @@ margin-left: 0.25rem; } +/* Callouts */ +.callout { + border-left: 4px solid var(--callout-border, var(--border-subtle)); + background: var(--callout-bg, var(--surface)); + border-radius: 4px; + padding: 0.75rem 1rem; + margin: 1rem 0; +} + +.callout-title { + font-weight: 600; + margin-bottom: 0.25rem; + color: var(--callout-title-color, var(--text)); +} + +.callout-note { + --callout-border: #448aff; + --callout-bg: #448aff10; + --callout-title-color: #448aff; +} +.callout-tip { + --callout-border: #00bfa5; + --callout-bg: #00bfa510; + --callout-title-color: #00bfa5; +} +.callout-important { + --callout-border: #ab47bc; + --callout-bg: #ab47bc10; + --callout-title-color: #ab47bc; +} +.callout-warning { + --callout-border: #ff9100; + --callout-bg: #ff910010; + --callout-title-color: #ff9100; +} +.callout-danger { + --callout-border: #ff5252; + --callout-bg: #ff525210; + --callout-title-color: #ff5252; +} +.callout-example { + --callout-border: #7c4dff; + --callout-bg: #7c4dff10; + --callout-title-color: #7c4dff; +} +.callout-quote { + --callout-border: var(--border-subtle); + --callout-bg: transparent; +} +.callout-abstract { + --callout-border: #00b8d4; + --callout-bg: #00b8d410; + --callout-title-color: #00b8d4; +} +.callout-todo { + --callout-border: #448aff; + --callout-bg: #448aff10; + --callout-title-color: #448aff; +} +.callout-success { + --callout-border: #00c853; + --callout-bg: #00c85310; + --callout-title-color: #00c853; +} +.callout-question { + --callout-border: #ffab00; + --callout-bg: #ffab0010; + --callout-title-color: #ffab00; +} +.callout-failure { + --callout-border: #ff5252; + --callout-bg: #ff525210; + --callout-title-color: #ff5252; +} + /* Task lists */ .task-list-item { list-style-type: none; diff --git a/src/lib/markdown.ts b/src/lib/markdown.ts index 60e6e20..b13e15d 100644 --- a/src/lib/markdown.ts +++ b/src/lib/markdown.ts @@ -2,6 +2,7 @@ import MarkdownIt from "markdown-it"; import footnotePlugin from "markdown-it-footnote"; import markPlugin from "markdown-it-mark"; import taskListPlugin from "markdown-it-task-lists"; +import { calloutPlugin } from "./markdown/callout-plugin.ts"; import { commentPlugin } from "./markdown/comment-plugin.ts"; import { headingAnchorPlugin } from "./markdown/heading-anchor-plugin.ts"; import { type KatexPluginEnv, katexPlugin } from "./markdown/katex-plugin.ts"; @@ -67,6 +68,7 @@ md.use(headingAnchorPlugin); md.use(footnotePlugin); md.use(markPlugin); md.use(taskListPlugin, { enabled: false, label: true }); +md.use(calloutPlugin); md.use(vizPlugin); md.use(katexPlugin, { throwOnError: false }); md.use(youtubePlugin); diff --git a/src/lib/markdown/callout-plugin.ts b/src/lib/markdown/callout-plugin.ts new file mode 100644 index 0000000..16c7812 --- /dev/null +++ b/src/lib/markdown/callout-plugin.ts @@ -0,0 +1,108 @@ +import type MarkdownIt from "markdown-it"; + +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 customTitle = match[2]?.trim() || null; + const title = customTitle || type.charAt(0).toUpperCase() + type.slice(1); + + open.type = "callout_open"; + open.tag = "div"; + open.attrSet("class", `callout callout-${type}`); + + const titleOpen = new state.Token("callout_title_open", "p", 1); + 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", "p", -1); + + 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) { + tokens[j]!.type = "callout_close"; + tokens[j]!.tag = "div"; + break; + } + } + } + } + }); + + mdi.renderer.rules["callout_open"] = (tokens, idx) => { + const token = tokens[idx]!; + const cls = token.attrGet("class") ?? "callout"; + return `
\n`; + }; + + mdi.renderer.rules["callout_close"] = () => "
\n"; + + mdi.renderer.rules["callout_title_open"] = (tokens, idx) => { + const token = tokens[idx]!; + const cls = token.attrGet("class") ?? "callout-title"; + return `

`; + }; + + mdi.renderer.rules["callout_title_close"] = () => "

\n"; +} diff --git a/tests/lib/markdown.test.ts b/tests/lib/markdown.test.ts index f4d9a26..134ab89 100644 --- a/tests/lib/markdown.test.ts +++ b/tests/lib/markdown.test.ts @@ -227,6 +227,46 @@ describe("highlights", () => { }); }); +describe("callouts", () => { + test("renders basic callout", () => { + const { html } = renderMarkdown("> [!NOTE]\n> This is a note."); + expect(html).toContain('class="callout callout-note"'); + expect(html).toContain("callout-title"); + expect(html).toContain("Note"); + expect(html).toContain("This is a note."); + }); + + test("renders custom title", () => { + const { html } = renderMarkdown("> [!WARNING] Be careful\n> Details here."); + expect(html).toContain("Be careful"); + expect(html).toContain("callout-warning"); + }); + + test("normalizes type aliases", () => { + const { html } = renderMarkdown("> [!HINT]\n> A hint."); + expect(html).toContain("callout-tip"); + }); + + test("unknown type uses raw name as class", () => { + const { html } = renderMarkdown("> [!CUSTOM]\n> Custom."); + expect(html).toContain("callout-custom"); + }); + + test("regular blockquotes are unaffected", () => { + const { html } = renderMarkdown("> Just a normal quote."); + expect(html).toContain("
"); + expect(html).not.toContain("callout"); + }); + + test("multi-paragraph callout", () => { + const src = "> [!TIP]\n> First para.\n>\n> Second para."; + const { html } = renderMarkdown(src); + expect(html).toContain("First para"); + expect(html).toContain("Second para"); + expect(html).toContain("callout-tip"); + }); +}); + describe("task lists", () => { test("renders unchecked task", () => { const { html } = renderMarkdown("- [ ] Todo item"); -- 2.51.2