diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 9819cbf033..217a2438ad 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -48,6 +48,7 @@ import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/me import { mentionDeletionPlugin } from "../lib/mention-deletion"; import { looksLikeMarkdownPaste } from "../lib/markdownPaste"; import { normalizeMarkdown } from "../lib/normalize-markdown"; +import { unescapeBlockquoteMarkers } from "../lib/blockquote-markdown"; import { pasteNormalizationPlugin } from "../lib/paste-normalization"; import { cn } from "../lib/utils"; import { useEditorAutocomplete, type SlashCommandOption } from "../context/EditorAutocompleteContext"; @@ -141,7 +142,10 @@ function convertHtmlImagesToMarkdown(text: string): string { function prepareMarkdownForEditor(value: string): string { const normalizedLineEndings = value.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); - return convertHtmlImagesToMarkdown(normalizedLineEndings); + // Recover escaped blockquotes (`\>`) so `>`-prefixed content renders as a real + // blockquote in the editor as well as on display (keeps import/export in sync). + const withBlockquotes = unescapeBlockquoteMarkers(normalizedLineEndings); + return convertHtmlImagesToMarkdown(withBlockquotes); } function escapeRegExp(value: string): string { @@ -1324,8 +1328,13 @@ export const MarkdownEditor = forwardRef suppressHtmlProcessing placeholder={placeholder} readOnly={readOnly} - onChange={(next) => { + onChange={(rawNext) => { if (readOnly) return; + // Recover blockquotes the exporter escaped as `\>` (see + // unescapeBlockquoteMarkers) so a `>`-prefixed line the user typed + // always survives as a real blockquote, even when the WYSIWYG + // shortcut didn't fire. + const next = unescapeBlockquoteMarkers(rawNext); const echo = echoIgnoreMarkdownRef.current; if (echo !== null && next === echo) { echoIgnoreMarkdownRef.current = null; diff --git a/ui/src/lib/blockquote-markdown.test.ts b/ui/src/lib/blockquote-markdown.test.ts new file mode 100644 index 0000000000..654f412232 --- /dev/null +++ b/ui/src/lib/blockquote-markdown.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { unescapeBlockquoteMarkers } from "./blockquote-markdown"; + +describe("unescapeBlockquoteMarkers", () => { + it("leaves markdown without escaped markers untouched", () => { + expect(unescapeBlockquoteMarkers("> quoted")).toBe("> quoted"); + expect(unescapeBlockquoteMarkers("plain text")).toBe("plain text"); + expect(unescapeBlockquoteMarkers("")).toBe(""); + }); + + it("unescapes a single escaped blockquote line", () => { + expect(unescapeBlockquoteMarkers("\\> see")).toBe("> see"); + }); + + it("unescapes multiple escaped blockquote lines", () => { + expect(unescapeBlockquoteMarkers("\\> line one\n\\> line two")).toBe("> line one\n> line two"); + }); + + it("unescapes escaped blockquotes interleaved with normal text", () => { + expect(unescapeBlockquoteMarkers("hello\n\n\\> quoted\n\nbye")).toBe("hello\n\n> quoted\n\nbye"); + }); + + it("preserves up to 3 spaces of block indent before the marker", () => { + expect(unescapeBlockquoteMarkers(" \\> indented")).toBe(" > indented"); + expect(unescapeBlockquoteMarkers(" \\> three")).toBe(" > three"); + }); + + it("does not touch an escaped marker in an indented code block (4+ spaces)", () => { + expect(unescapeBlockquoteMarkers(" \\> literal")).toBe(" \\> literal"); + expect(unescapeBlockquoteMarkers("\tcode \\> x")).toBe("\tcode \\> x"); + }); + + it("does not touch an escaped marker carrying a blockquote container prefix", () => { + // A code fence nested inside a blockquote has a `> ` prefix on each line, so + // its `\>` content must stay escaped. + expect(unescapeBlockquoteMarkers("> \\> nested")).toBe("> \\> nested"); + }); + + it("does not touch an escaped marker inside a list item", () => { + expect(unescapeBlockquoteMarkers("- \\> item")).toBe("- \\> item"); + }); + + it("does not touch escaped markers inside fenced code blocks", () => { + const input = "```\n\\> not a quote\n```\n\\> real quote"; + expect(unescapeBlockquoteMarkers(input)).toBe("```\n\\> not a quote\n```\n> real quote"); + }); + + it("does not touch escaped markers inside a list-nested fenced code block", () => { + const input = "- ```\n \\> literal\n ```\n\\> real quote"; + expect(unescapeBlockquoteMarkers(input)).toBe("- ```\n \\> literal\n ```\n> real quote"); + }); + + it("does not close a list-nested fence on a list-marker content line", () => { + const input = "- ```\n - ```\n \\> literal\n ```\n\\> real quote"; + expect(unescapeBlockquoteMarkers(input)).toBe("- ```\n - ```\n \\> literal\n ```\n> real quote"); + }); + + it("tracks the continuation indent of an ordered-list fence", () => { + const input = "10. ```\n \\> literal\n ```\n\\> real quote"; + expect(unescapeBlockquoteMarkers(input)).toBe("10. ```\n \\> literal\n ```\n> real quote"); + }); + + it("tracks fenced code blocks through blockquote container prefixes", () => { + const input = "> ```\n> \\> literal\n> ```\n\\> real quote"; + expect(unescapeBlockquoteMarkers(input)).toBe("> ```\n> \\> literal\n> ```\n> real quote"); + }); + + it("handles tilde fences", () => { + const input = "~~~\n\\> literal\n~~~"; + expect(unescapeBlockquoteMarkers(input)).toBe("~~~\n\\> literal\n~~~"); + }); + + it("only rewrites the leading marker, not later text", () => { + expect(unescapeBlockquoteMarkers("\\> a \\> b")).toBe("> a \\> b"); + }); + + it("leaves a mid-line escaped marker alone", () => { + expect(unescapeBlockquoteMarkers("text \\> not a quote")).toBe("text \\> not a quote"); + }); + + it("does not close a fence on a shorter same-char run (nested fence content stays code)", () => { + // A 3-backtick line inside a 4-backtick fence is code content, not a close, + // so the escaped marker after it must remain escaped. + const input = "````\n\\> a\n```\n\\> b\n````\n\\> real"; + expect(unescapeBlockquoteMarkers(input)).toBe("````\n\\> a\n```\n\\> b\n````\n> real"); + }); + + it("does not treat a fence-like line with trailing content as a closing fence", () => { + const input = "```\n\\> code\n``` not a close\n\\> still code\n```\n\\> real"; + expect(unescapeBlockquoteMarkers(input)).toBe("```\n\\> code\n``` not a close\n\\> still code\n```\n> real"); + }); + + it("does not close a backtick fence with a tilde fence", () => { + const input = "```\n\\> code\n~~~\n\\> still code\n```\n\\> real"; + expect(unescapeBlockquoteMarkers(input)).toBe("```\n\\> code\n~~~\n\\> still code\n```\n> real"); + }); + + it("closes on a longer run than the opening fence", () => { + const input = "```\n\\> code\n`````\n\\> real"; + expect(unescapeBlockquoteMarkers(input)).toBe("```\n\\> code\n`````\n> real"); + }); + + it("treats a backtick fence with a backtick in the info string as non-opening", () => { + // `` ```` `` with `x` after is not a valid opening fence, so following + // escaped markers are ordinary paragraph text and get recovered. + const input = "``` `x`\n\\> real"; + expect(unescapeBlockquoteMarkers(input)).toBe("``` `x`\n> real"); + }); + + it("keeps an info string on the opening fence and still protects contents", () => { + const input = "```ts\n\\> code\n```\n\\> real"; + expect(unescapeBlockquoteMarkers(input)).toBe("```ts\n\\> code\n```\n> real"); + }); +}); diff --git a/ui/src/lib/blockquote-markdown.ts b/ui/src/lib/blockquote-markdown.ts new file mode 100644 index 0000000000..d2b6332ce2 --- /dev/null +++ b/ui/src/lib/blockquote-markdown.ts @@ -0,0 +1,112 @@ +/** + * When the WYSIWYG blockquote shortcut does not fire (e.g. the `> ` prefix is + * assembled by an edit that Lexical's markdown-shortcut transform doesn't catch, + * which happens on some browsers/IMEs), MDXEditor exports the paragraph as an + * *escaped* blockquote — `\> text`. `mdast-util-to-markdown` escapes a leading + * `>` so a literal paragraph round-trips as text rather than a blockquote. + * + * In this product `>` at the start of a line always means "blockquote" (there is + * no separate literal-`>` affordance and the composer has no blockquote toolbar + * button), so an escaped `\>` is never the user's intent — it is a silently + * dropped blockquote. This helper rewrites a leading `\>` back to `>` so the + * stored markdown renders as the blockquote the user typed. + * + * Only a marker at the block-level line start is rewritten. The exporter escapes + * a `>` exactly where CommonMark would otherwise start a blockquote — the first + * column of a block, allowing the 0–3 spaces of insignificant indent. Restricting + * to `^ {0,3}\>` deliberately skips: + * - indented code blocks (4+ spaces of indent), whose `\>` is literal content; + * - content nested in a blockquote or list, whose lines carry a `>`/`-`/`digit.` + * container prefix rather than plain indent. + * + * Fenced code blocks are also skipped, including fences nested in blockquote or + * list containers: their contents are never `\`-escaped by the exporter, and a + * `\>` inside a code fence is meaningful literal text. Fence tracking follows + * CommonMark: a closing fence must use the same character as the opening fence, + * be at least as long, and carry no trailing content (an info string is only + * allowed on the opening fence). + */ + +// An opening fence may follow blockquote/list container markers. Capture the +// whole prefix so the closing scan can preserve that container context. +const FENCE_OPEN_RE = + /^( {0,3}(?:(?:> ?|(?:[-+*]|\d{1,9}[.)]) +))*)(`{3,}|~{3,})(.*)$/; +const LIST_MARKER_RE = /(?:[-+*]|\d{1,9}[.)]) +/g; +const BLOCKQUOTE_MARKER_RE = />/g; +const FENCE_CLOSE_RE = /^( *)(`{3,}|~{3,})[ \t]*$/; +// A block-level escaped blockquote marker: `\>` at column 0, allowing only the +// 0–3 spaces of insignificant leading indent CommonMark permits before a block. +const ESCAPED_BLOCKQUOTE_RE = /^( {0,3})\\>/; + +function stripBlockquotePrefix(line: string, depth: number): string | null { + let rest = line; + + for (let i = 0; i < depth; i += 1) { + const marker = /^ {0,3}> ?/.exec(rest); + if (!marker) return null; + rest = rest.slice(marker[0].length); + } + + return rest; +} + +export function unescapeBlockquoteMarkers(markdown: string): string { + if (!markdown.includes("\\>")) return markdown; + + const lines = markdown.split("\n"); + let fenceChar = ""; // "" when not inside a fenced code block + let fenceLen = 0; + let fenceBlockquoteDepth = 0; + let fenceCloseIndentMax = 3; + + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i]; + + if (fenceChar) { + const closeCandidate = stripBlockquotePrefix(line, fenceBlockquoteDepth); + const closeMatch = closeCandidate ? FENCE_CLOSE_RE.exec(closeCandidate) : null; + + if (closeMatch) { + const indent = closeMatch[1].length; + const run = closeMatch[2]; + if (indent <= fenceCloseIndentMax && run[0] === fenceChar && run.length >= fenceLen) { + fenceChar = ""; + fenceLen = 0; + fenceBlockquoteDepth = 0; + fenceCloseIndentMax = 3; + } + } + + continue; + } + + const fenceMatch = FENCE_OPEN_RE.exec(line); + + if (fenceMatch) { + const prefix = fenceMatch[1]; + const run = fenceMatch[2]; + const char = run[0]; + const rest = fenceMatch[3]; + + // A backtick info string may not itself contain a backtick (CommonMark); + // such a line is not a valid opening fence. + if (!(char === "`" && rest.includes("`"))) { + const listMarkers = prefix.match(LIST_MARKER_RE) ?? []; + fenceChar = char; + fenceLen = run.length; + fenceBlockquoteDepth = (prefix.match(BLOCKQUOTE_MARKER_RE) ?? []).length; + // A list's continuation indent includes its marker and following spaces. + // A closing fence may add CommonMark's normal 0–3 spaces after that. + fenceCloseIndentMax = + (listMarkers.length > 0 ? listMarkers.reduce((sum, marker) => sum + marker.length, 0) : 0) + 3; + continue; + } + } + + if (ESCAPED_BLOCKQUOTE_RE.test(line)) { + lines[i] = line.replace(ESCAPED_BLOCKQUOTE_RE, "$1>"); + } + } + + return lines.join("\n"); +}