From 3554cbf28b21b692bddf2bdaa412800f802bbf2d Mon Sep 17 00:00:00 2001 From: "mathmathou@gmail.com" Date: Thu, 18 Jun 2026 15:19:11 +1100 Subject: [PATCH 1/3] feat: add GitHub-style alert blockquotes Adds support for the 5 GitHub alert types (NOTE, TIP, IMPORTANT, WARNING, CAUTION) as a new TipTap extension with full markdown round-trip support. - AlertBlockquote.ts: new Node extension with custom marked.js tokenizer, parseMarkdown, renderMarkdown, and InputRule - Editor.tsx: registers extension + adds 5 toolbar buttons with active state - SlashCommand.tsx: adds /note, /tip, /important, /warning, /caution commands - App.css: alert styles (color-coded border + label), fixes blockquote bold/italic default from Tailwind Typography --- src/App.css | 56 ++++++++++++ src/components/editor/AlertBlockquote.ts | 107 +++++++++++++++++++++++ src/components/editor/Editor.tsx | 28 ++++++ src/components/editor/SlashCommand.tsx | 21 +++++ 4 files changed, 212 insertions(+) create mode 100644 src/components/editor/AlertBlockquote.ts diff --git a/src/App.css b/src/App.css index 3b3904d9..4d5bf52d 100644 --- a/src/App.css +++ b/src/App.css @@ -644,8 +644,64 @@ html.dark { color: var(--color-text-muted); border-left-color: var(--color-border); border-inline-start-color: var(--color-border); + font-weight: normal; + font-style: normal; } +/* Remove Tailwind Typography default quote marks on blockquote paragraphs */ +.prose + :where(blockquote p:first-of-type):not( + :where([class~="not-prose"], [class~="not-prose"] *) + )::before, +.prose + :where(blockquote p:last-of-type):not( + :where([class~="not-prose"], [class~="not-prose"] *) + )::after { + content: ""; +} + +/* Alert blockquotes */ +.prose blockquote.alert { + font-weight: normal; + font-style: normal; + color: var(--color-text); + border-left-width: 3px; + border-left-style: solid; + padding: 0.6em 1em; + margin: 1em 0; + border-radius: 0 4px 4px 0; + background-color: var(--color-bg-muted); +} + +.prose blockquote.alert::before { + display: block; + font-weight: 600; + font-size: 0.8em; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 0.4em; +} + +.prose blockquote.alert p { + margin: 0; + color: var(--color-text); +} + +.prose blockquote.alert-note { border-left-color: #4493f8; } +.prose blockquote.alert-note::before { content: "Note"; color: #4493f8; } + +.prose blockquote.alert-tip { border-left-color: #3fb950; } +.prose blockquote.alert-tip::before { content: "Tip"; color: #3fb950; } + +.prose blockquote.alert-important { border-left-color: #ab7df8; } +.prose blockquote.alert-important::before { content: "Important"; color: #ab7df8; } + +.prose blockquote.alert-warning { border-left-color: #d29922; } +.prose blockquote.alert-warning::before { content: "Warning"; color: #d29922; } + +.prose blockquote.alert-caution { border-left-color: #f85149; } +.prose blockquote.alert-caution::before { content: "Caution"; color: #f85149; } + /* Search match highlighting - uses the selection color */ .search-match { background-color: color-mix(in srgb, var(--color-selection), transparent 50%); diff --git a/src/components/editor/AlertBlockquote.ts b/src/components/editor/AlertBlockquote.ts new file mode 100644 index 00000000..53ff6e13 --- /dev/null +++ b/src/components/editor/AlertBlockquote.ts @@ -0,0 +1,107 @@ +import { Node, mergeAttributes, InputRule, type JSONContent, type MarkdownToken } from "@tiptap/core"; + +export type AlertType = "NOTE" | "TIP" | "IMPORTANT" | "WARNING" | "CAUTION"; + +const ALERT_TYPES: AlertType[] = ["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"]; + + +export const AlertBlockquote = Node.create({ + name: "alertBlockquote", + group: "block", + content: "block+", + defining: true, + + addAttributes() { + return { + alertType: { + default: "NOTE", + parseHTML: (el) => (el.getAttribute("data-alert-type") as AlertType) || "NOTE", + renderHTML: (attrs) => ({ "data-alert-type": attrs.alertType }), + }, + }; + }, + + parseHTML() { + return [{ tag: "blockquote[data-alert-type]" }]; + }, + + renderHTML({ node, HTMLAttributes }) { + const type = ((node.attrs.alertType as string) || "NOTE").toLowerCase(); + return [ + "blockquote", + mergeAttributes(HTMLAttributes, { class: `alert alert-${type}` }), + 0, + ]; + }, + + addInputRules() { + const nodeType = this.type; + return ALERT_TYPES.map((alertType) => + new InputRule({ + find: new RegExp(`^\\[!${alertType}\\]\\s$`, "i"), + handler: ({ state, range, commands }) => { + const { $from } = state.selection; + let bqPos = -1; + for (let d = $from.depth; d >= 1; d--) { + if ($from.node(d).type.name === "blockquote") { + bqPos = $from.before(d); + break; + } + } + if (bqPos === -1) return; + + commands.command(({ tr }) => { + tr.delete(range.from, range.to); + const bq = tr.doc.nodeAt(bqPos); + if (!bq) return true; + const alertNode = nodeType.create({ alertType }, bq.content); + tr.replaceWith(bqPos, bqPos + bq.nodeSize, alertNode); + return true; + }); + }, + }), + ); + }, + + markdownTokenName: "alertBlockquote", + + markdownTokenizer: { + name: "alertBlockquote", + level: "block" as const, + start: "> [!", + tokenize(src: string, _tokens: MarkdownToken[]) { + const match = src.match( + /^> \[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*\r?\n?((?:>[ \t]?[^\n]*\r?\n?)*)/i, + ); + if (!match) return undefined; + const text = (match[2] || "").replace(/^>[ \t]?/gm, "").replace(/\s+$/, ""); + return { + type: "alertBlockquote", + raw: match[0], + alertType: match[1].toUpperCase(), + text, + }; + }, + }, + + parseMarkdown(token: MarkdownToken, helpers) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const t = token as any; + const alertType: string = t.alertType || "NOTE"; + const text: string = t.text || ""; + // Split on blank lines → multiple paragraphs; soft newlines → space + const blocks = text.split(/\n\n+/).map((p) => p.replace(/\n/g, " ").trim()).filter(Boolean); + const paraNodes = blocks.length > 0 + ? blocks.map((p) => helpers.createNode("paragraph", {}, [helpers.createTextNode(p)])) + : [helpers.createNode("paragraph", {}, [])]; + return helpers.createNode("alertBlockquote", { alertType }, paraNodes); + }, + + renderMarkdown(node: JSONContent, helpers) { + const alertType = (node.attrs?.alertType as string) || "NOTE"; + const raw = node.content ? helpers.renderChildren(node.content) : ""; + const inner = raw.replace(/\s+$/, ""); + const lines = inner.split("\n").map((l) => (l ? `> ${l}` : ">")); + return `> [!${alertType}]\n${lines.join("\n")}`; + }, +}); diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index dcf07161..bdb9503e 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -69,6 +69,7 @@ import { Wikilink, type WikilinkStorage } from "./Wikilink"; import { WikilinkSuggestion } from "./WikilinkSuggestion"; import { EditorWidthHandles } from "./EditorWidthHandle"; import { ScratchBlockMath, normalizeBlockMath } from "./MathExtensions"; +import { AlertBlockquote } from "./AlertBlockquote"; import { cn } from "../../lib/utils"; import { plainTextFromMarkdown } from "../../lib/plainText"; import { Button, IconButton, ToolbarButton, Tooltip } from "../ui"; @@ -345,6 +346,32 @@ function FormatBar({ > + {( + [ + { alertType: "NOTE", label: "Note", color: "#4493f8" }, + { alertType: "TIP", label: "Tip", color: "#3fb950" }, + { alertType: "IMPORTANT", label: "Important", color: "#ab7df8" }, + { alertType: "WARNING", label: "Warning", color: "#d29922" }, + { alertType: "CAUTION", label: "Caution", color: "#f85149" }, + ] as { alertType: string; label: string; color: string }[] + ).map(({ alertType, label, color }) => ( + + editor.chain().focus().wrapIn("alertBlockquote", { alertType }).run() + } + isActive={editor.isActive("alertBlockquote", { alertType })} + title={`Alert: ${label}`} + > + + + + + + ))} editor.chain().focus().toggleCode().run()} isActive={editor.isActive("code")} @@ -1109,6 +1136,7 @@ export function Editor({ }, }), Frontmatter, + AlertBlockquote, Markdown.configure({}), SearchHighlight.configure({ matches: [], diff --git a/src/components/editor/SlashCommand.tsx b/src/components/editor/SlashCommand.tsx index 370a242a..e4af2adc 100644 --- a/src/components/editor/SlashCommand.tsx +++ b/src/components/editor/SlashCommand.tsx @@ -23,6 +23,7 @@ import { BracketsIcon, WorkflowIcon, } from "../icons"; +import type { AlertType } from "./AlertBlockquote"; import { SlashCommandList, type SlashCommandListRef } from "./SlashCommandList"; export interface SlashCommandItem { @@ -115,6 +116,26 @@ const SLASH_COMMANDS: SlashCommandItem[] = [ editor.chain().focus().toggleBlockquote().run(); }, }, + ...([ + { type: "NOTE", label: "Note", color: "#4493f8", aliases: ["note", "info"] }, + { type: "TIP", label: "Tip", color: "#3fb950", aliases: ["tip", "hint"] }, + { type: "IMPORTANT", label: "Important", color: "#ab7df8", aliases: ["important" ] }, + { type: "WARNING", label: "Warning", color: "#d29922", aliases: ["warning", "warn"] }, + { type: "CAUTION", label: "Caution", color: "#f85149", aliases: ["caution", "danger"] }, + ] as { type: AlertType; label: string; color: string; aliases: string[] }[]).map(({ type, label, color, aliases }) => ({ + title: `Alert: ${label}`, + description: `${label} alert callout`, + icon: ( +
+ + +
+ ), + aliases: [...aliases, "alert", "callout"], + command: (editor: TiptapEditor) => { + editor.chain().focus().wrapIn("alertBlockquote", { alertType: type }).run(); + }, + })), { title: "Code Block", description: "Fenced code block", From 10685783f89a7f775bc5e4e44f7ae7ece000c8d1 Mon Sep 17 00:00:00 2001 From: "mathmathou@gmail.com" Date: Fri, 19 Jun 2026 08:21:39 +1100 Subject: [PATCH 2/3] fix: address CodeRabbit review feedback on alert blockquotes - Add normalizeAlertType() to validate alertType everywhere it's read from HTML attrs, markdown tokens, or node attrs - Add aria-label to alert blockquotes for screen reader accessibility - Extract ALERT_TYPES/ALERT_META into AlertBlockquote.ts as a single source of truth, used by both Editor.tsx and SlashCommand.tsx - Use logical CSS properties (border-inline-start, border-*-end-radius) instead of border-left for RTL support --- src/App.css | 17 ++++---- src/components/editor/AlertBlockquote.ts | 33 ++++++++++++---- src/components/editor/Editor.tsx | 49 +++++++++++------------- src/components/editor/SlashCommand.tsx | 47 +++++++++++++---------- 4 files changed, 83 insertions(+), 63 deletions(-) diff --git a/src/App.css b/src/App.css index 4d5bf52d..efaf6605 100644 --- a/src/App.css +++ b/src/App.css @@ -665,11 +665,12 @@ html.dark { font-weight: normal; font-style: normal; color: var(--color-text); - border-left-width: 3px; - border-left-style: solid; + border-inline-start-width: 3px; + border-inline-start-style: solid; padding: 0.6em 1em; margin: 1em 0; - border-radius: 0 4px 4px 0; + border-start-end-radius: 4px; + border-end-end-radius: 4px; background-color: var(--color-bg-muted); } @@ -687,19 +688,19 @@ html.dark { color: var(--color-text); } -.prose blockquote.alert-note { border-left-color: #4493f8; } +.prose blockquote.alert-note { border-inline-start-color: #4493f8; } .prose blockquote.alert-note::before { content: "Note"; color: #4493f8; } -.prose blockquote.alert-tip { border-left-color: #3fb950; } +.prose blockquote.alert-tip { border-inline-start-color: #3fb950; } .prose blockquote.alert-tip::before { content: "Tip"; color: #3fb950; } -.prose blockquote.alert-important { border-left-color: #ab7df8; } +.prose blockquote.alert-important { border-inline-start-color: #ab7df8; } .prose blockquote.alert-important::before { content: "Important"; color: #ab7df8; } -.prose blockquote.alert-warning { border-left-color: #d29922; } +.prose blockquote.alert-warning { border-inline-start-color: #d29922; } .prose blockquote.alert-warning::before { content: "Warning"; color: #d29922; } -.prose blockquote.alert-caution { border-left-color: #f85149; } +.prose blockquote.alert-caution { border-inline-start-color: #f85149; } .prose blockquote.alert-caution::before { content: "Caution"; color: #f85149; } /* Search match highlighting - uses the selection color */ diff --git a/src/components/editor/AlertBlockquote.ts b/src/components/editor/AlertBlockquote.ts index 53ff6e13..23ec4026 100644 --- a/src/components/editor/AlertBlockquote.ts +++ b/src/components/editor/AlertBlockquote.ts @@ -2,7 +2,22 @@ import { Node, mergeAttributes, InputRule, type JSONContent, type MarkdownToken export type AlertType = "NOTE" | "TIP" | "IMPORTANT" | "WARNING" | "CAUTION"; -const ALERT_TYPES: AlertType[] = ["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"]; +export const ALERT_TYPES: AlertType[] = ["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"]; + +// Single source of truth for label/color, shared by the toolbar buttons, +// slash commands, and the accessible label rendered on each alert. +export const ALERT_META: Record = { + NOTE: { label: "Note", color: "#4493f8" }, + TIP: { label: "Tip", color: "#3fb950" }, + IMPORTANT: { label: "Important", color: "#ab7df8" }, + WARNING: { label: "Warning", color: "#d29922" }, + CAUTION: { label: "Caution", color: "#f85149" }, +}; + +function normalizeAlertType(value: unknown): AlertType { + const upper = typeof value === "string" ? value.toUpperCase() : ""; + return (ALERT_TYPES as string[]).includes(upper) ? (upper as AlertType) : "NOTE"; +} export const AlertBlockquote = Node.create({ @@ -15,7 +30,7 @@ export const AlertBlockquote = Node.create({ return { alertType: { default: "NOTE", - parseHTML: (el) => (el.getAttribute("data-alert-type") as AlertType) || "NOTE", + parseHTML: (el) => normalizeAlertType(el.getAttribute("data-alert-type")), renderHTML: (attrs) => ({ "data-alert-type": attrs.alertType }), }, }; @@ -26,10 +41,14 @@ export const AlertBlockquote = Node.create({ }, renderHTML({ node, HTMLAttributes }) { - const type = ((node.attrs.alertType as string) || "NOTE").toLowerCase(); + const alertType = normalizeAlertType(node.attrs.alertType); + const { label } = ALERT_META[alertType]; return [ "blockquote", - mergeAttributes(HTMLAttributes, { class: `alert alert-${type}` }), + mergeAttributes(HTMLAttributes, { + class: `alert alert-${alertType.toLowerCase()}`, + "aria-label": `${label} callout`, + }), 0, ]; }, @@ -78,7 +97,7 @@ export const AlertBlockquote = Node.create({ return { type: "alertBlockquote", raw: match[0], - alertType: match[1].toUpperCase(), + alertType: normalizeAlertType(match[1]), text, }; }, @@ -87,7 +106,7 @@ export const AlertBlockquote = Node.create({ parseMarkdown(token: MarkdownToken, helpers) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const t = token as any; - const alertType: string = t.alertType || "NOTE"; + const alertType = normalizeAlertType(t.alertType); const text: string = t.text || ""; // Split on blank lines → multiple paragraphs; soft newlines → space const blocks = text.split(/\n\n+/).map((p) => p.replace(/\n/g, " ").trim()).filter(Boolean); @@ -98,7 +117,7 @@ export const AlertBlockquote = Node.create({ }, renderMarkdown(node: JSONContent, helpers) { - const alertType = (node.attrs?.alertType as string) || "NOTE"; + const alertType = normalizeAlertType(node.attrs?.alertType); const raw = node.content ? helpers.renderChildren(node.content) : ""; const inner = raw.replace(/\s+$/, ""); const lines = inner.split("\n").map((l) => (l ? `> ${l}` : ">")); diff --git a/src/components/editor/Editor.tsx b/src/components/editor/Editor.tsx index bdb9503e..d89f9930 100644 --- a/src/components/editor/Editor.tsx +++ b/src/components/editor/Editor.tsx @@ -69,7 +69,7 @@ import { Wikilink, type WikilinkStorage } from "./Wikilink"; import { WikilinkSuggestion } from "./WikilinkSuggestion"; import { EditorWidthHandles } from "./EditorWidthHandle"; import { ScratchBlockMath, normalizeBlockMath } from "./MathExtensions"; -import { AlertBlockquote } from "./AlertBlockquote"; +import { AlertBlockquote, ALERT_TYPES, ALERT_META } from "./AlertBlockquote"; import { cn } from "../../lib/utils"; import { plainTextFromMarkdown } from "../../lib/plainText"; import { Button, IconButton, ToolbarButton, Tooltip } from "../ui"; @@ -346,32 +346,27 @@ function FormatBar({ >
- {( - [ - { alertType: "NOTE", label: "Note", color: "#4493f8" }, - { alertType: "TIP", label: "Tip", color: "#3fb950" }, - { alertType: "IMPORTANT", label: "Important", color: "#ab7df8" }, - { alertType: "WARNING", label: "Warning", color: "#d29922" }, - { alertType: "CAUTION", label: "Caution", color: "#f85149" }, - ] as { alertType: string; label: string; color: string }[] - ).map(({ alertType, label, color }) => ( - - editor.chain().focus().wrapIn("alertBlockquote", { alertType }).run() - } - isActive={editor.isActive("alertBlockquote", { alertType })} - title={`Alert: ${label}`} - > - - - - - - ))} + {ALERT_TYPES.map((alertType) => { + const { label, color } = ALERT_META[alertType]; + return ( + + editor.chain().focus().wrapIn("alertBlockquote", { alertType }).run() + } + isActive={editor.isActive("alertBlockquote", { alertType })} + title={`Alert: ${label}`} + > + + + + + + ); + })} editor.chain().focus().toggleCode().run()} isActive={editor.isActive("code")} diff --git a/src/components/editor/SlashCommand.tsx b/src/components/editor/SlashCommand.tsx index e4af2adc..7183e122 100644 --- a/src/components/editor/SlashCommand.tsx +++ b/src/components/editor/SlashCommand.tsx @@ -23,7 +23,15 @@ import { BracketsIcon, WorkflowIcon, } from "../icons"; -import type { AlertType } from "./AlertBlockquote"; +import { ALERT_TYPES, ALERT_META, type AlertType } from "./AlertBlockquote"; + +const ALERT_ALIASES: Record = { + NOTE: ["note", "info"], + TIP: ["tip", "hint"], + IMPORTANT: ["important"], + WARNING: ["warning", "warn"], + CAUTION: ["caution", "danger"], +}; import { SlashCommandList, type SlashCommandListRef } from "./SlashCommandList"; export interface SlashCommandItem { @@ -116,26 +124,23 @@ const SLASH_COMMANDS: SlashCommandItem[] = [ editor.chain().focus().toggleBlockquote().run(); }, }, - ...([ - { type: "NOTE", label: "Note", color: "#4493f8", aliases: ["note", "info"] }, - { type: "TIP", label: "Tip", color: "#3fb950", aliases: ["tip", "hint"] }, - { type: "IMPORTANT", label: "Important", color: "#ab7df8", aliases: ["important" ] }, - { type: "WARNING", label: "Warning", color: "#d29922", aliases: ["warning", "warn"] }, - { type: "CAUTION", label: "Caution", color: "#f85149", aliases: ["caution", "danger"] }, - ] as { type: AlertType; label: string; color: string; aliases: string[] }[]).map(({ type, label, color, aliases }) => ({ - title: `Alert: ${label}`, - description: `${label} alert callout`, - icon: ( -
- - -
- ), - aliases: [...aliases, "alert", "callout"], - command: (editor: TiptapEditor) => { - editor.chain().focus().wrapIn("alertBlockquote", { alertType: type }).run(); - }, - })), + ...ALERT_TYPES.map((type) => { + const { label, color } = ALERT_META[type]; + return { + title: `Alert: ${label}`, + description: `${label} alert callout`, + icon: ( +
+ + +
+ ), + aliases: [...ALERT_ALIASES[type], "alert", "callout"], + command: (editor: TiptapEditor) => { + editor.chain().focus().wrapIn("alertBlockquote", { alertType: type }).run(); + }, + }; + }), { title: "Code Block", description: "Fenced code block", From 8440e6e85c487d9a1d9e1d8d6aae5164b5c4b1b7 Mon Sep 17 00:00:00 2001 From: "mathmathou@gmail.com" Date: Fri, 19 Jun 2026 08:54:07 +1100 Subject: [PATCH 3/3] fix: derive alert tokenizer regex from ALERT_TYPES Per CodeRabbit follow-up: the markdown tokenizer regex previously hardcoded the 5 alert type names separately from ALERT_TYPES. Now built dynamically from ALERT_TYPES so the two can't drift out of sync. --- src/components/editor/AlertBlockquote.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/components/editor/AlertBlockquote.ts b/src/components/editor/AlertBlockquote.ts index 23ec4026..3096416f 100644 --- a/src/components/editor/AlertBlockquote.ts +++ b/src/components/editor/AlertBlockquote.ts @@ -19,6 +19,12 @@ function normalizeAlertType(value: unknown): AlertType { return (ALERT_TYPES as string[]).includes(upper) ? (upper as AlertType) : "NOTE"; } +// Built from ALERT_TYPES so the tokenizer always matches exactly the types we support +const ALERT_TOKEN_RE = new RegExp( + `^> \\[!(${ALERT_TYPES.join("|")})\\][ \\t]*\\r?\\n?((?:>[ \\t]?[^\\n]*\\r?\\n?)*)`, + "i", +); + export const AlertBlockquote = Node.create({ name: "alertBlockquote", @@ -89,9 +95,7 @@ export const AlertBlockquote = Node.create({ level: "block" as const, start: "> [!", tokenize(src: string, _tokens: MarkdownToken[]) { - const match = src.match( - /^> \[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\][ \t]*\r?\n?((?:>[ \t]?[^\n]*\r?\n?)*)/i, - ); + const match = src.match(ALERT_TOKEN_RE); if (!match) return undefined; const text = (match[2] || "").replace(/^>[ \t]?/gm, "").replace(/\s+$/, ""); return {