From 0a66096eb3254c21e619f8e5d10e65082a7de63d Mon Sep 17 00:00:00 2001 From: "valentin.tsvetkov@testomat.io" Date: Tue, 6 Oct 2026 13:23:16 +0400 Subject: [PATCH 1/2] Handle code spans per CommonMark Add shared helpers in src/editor/codeSpan.ts and use them in both the converter and StepField so backticks parse the way a standard Markdown renderer does: - Spans open and close with equal-length backtick runs, so ``a`b`` is the code "a`b"; an unmatched run stays literal text - Inline code containing backticks is wrapped in a longer delimiter instead of escaped, since backslash escapes do not work inside code - One-line ```code``` reads as an inline code span, not a code block - Unescaping skips code regions, keeping backslashes literal there --- src/editor/blocks/stepField.tsx | 64 ++-- src/editor/blocks/stepFieldFormatting.test.ts | 42 +++ src/editor/codeSpan.test.ts | 54 ++++ src/editor/codeSpan.ts | 129 ++++++++ src/editor/customMarkdownConverter.test.ts | 180 +++++++++-- src/editor/customMarkdownConverter.ts | 285 +++++++++++------- 6 files changed, 607 insertions(+), 147 deletions(-) create mode 100644 src/editor/codeSpan.test.ts create mode 100644 src/editor/codeSpan.ts diff --git a/src/editor/blocks/stepField.tsx b/src/editor/blocks/stepField.tsx index 3939d7c..ca52163 100644 --- a/src/editor/blocks/stepField.tsx +++ b/src/editor/blocks/stepField.tsx @@ -7,6 +7,7 @@ import { useStepAutocomplete, type StepSuggestion } from "../stepAutocomplete"; import { type SnippetSuggestion } from "../snippetAutocomplete"; import { useStepImageUpload } from "../stepImageUpload"; import { escapeMarkdownText, normalizePlainText } from "./markdown"; +import { backtickRunLength, codeBlockFence, findBacktickRun, scanCodeSpan, wrapCodeSpan } from "../codeSpan"; import { useAutoResize } from "./useAutoResize"; type Suggestion = StepSuggestion | SnippetSuggestion; @@ -167,7 +168,7 @@ function getActiveFormats( } -function stripInlineMarkdown(markdown: string): { +export function stripInlineMarkdown(markdown: string): { plainText: string; links: LinkMeta[]; formatting: FormattingMeta[]; @@ -291,32 +292,36 @@ function stripInlineMarkdown(markdown: string): { } } - // Code block: ```\n...\n``` (triple backticks with newlines) - if (markdown[i] === "`" && markdown[i + 1] === "`" && markdown[i + 2] === "`") { - const contentStart = markdown[i + 3] === "\n" ? i + 4 : i + 3; - const closeIdx = markdown.indexOf("```", contentStart); - if (closeIdx !== -1) { - const contentEnd = markdown[closeIdx - 1] === "\n" ? closeIdx - 1 : closeIdx; - const inner = markdown.slice(contentStart, contentEnd); - const start = plainText.length; - plainText += inner; - formatting.push({ start, end: plainText.length, type: "code" }); - i = closeIdx + 3; - continue; + if (markdown[i] === "`") { + const run = backtickRunLength(markdown, i); + // Code block: ```\n...\n``` — a fence of 3+ backticks closed by a run of + // the same length with a line break in between. Kept multi-line, unlike + // a code span whose line breaks collapse to spaces. + if (run >= 3) { + const contentStart = markdown[i + run] === "\n" ? i + run + 1 : i + run; + const closeIdx = findBacktickRun(markdown, contentStart, run); + if (closeIdx !== -1 && markdown.slice(i + run, closeIdx).includes("\n")) { + const contentEnd = markdown[closeIdx - 1] === "\n" ? closeIdx - 1 : closeIdx; + const start = plainText.length; + plainText += markdown.slice(contentStart, Math.max(contentStart, contentEnd)); + formatting.push({ start, end: plainText.length, type: "code" }); + i = closeIdx + run; + continue; + } } - } - // Inline code: `text` - if (markdown[i] === "`") { - const closeIdx = markdown.indexOf("`", i + 1); - if (closeIdx !== -1) { - const inner = markdown.slice(i + 1, closeIdx); + // Inline code: `text`, ``te`xt``, ```text``` — CommonMark code span. + // An unmatched run stays literal text. + const scan = scanCodeSpan(markdown, i)!; + if (scan.kind === "span") { const start = plainText.length; - plainText += inner; + plainText += scan.content; formatting.push({ start, end: plainText.length, type: "code" }); - i = closeIdx + 1; - continue; + } else { + plainText += markdown.slice(i, scan.end); } + i = scan.end; + continue; } plainText += markdown[i]; @@ -340,9 +345,18 @@ export function buildFullMarkdown(plainText: string, links: LinkMeta[], formatti let closeMarker: string; if (fmt.type === "code") { const content = plainText.slice(fmt.start, fmt.end); - const isMultiline = content.includes("\n"); - openMarker = isMultiline ? "```\n" : "`"; - closeMarker = isMultiline ? "\n```" : "`"; + if (content.includes("\n")) { + const fence = codeBlockFence(content); + openMarker = `${fence}\n`; + closeMarker = `\n${fence}`; + } else { + // A delimiter longer than any backtick run inside, padded when the + // content touches a backtick — split back into open/close halves. + const wrapped = wrapCodeSpan(content); + const delimiterLength = (wrapped.length - content.length) / 2; + openMarker = wrapped.slice(0, delimiterLength); + closeMarker = wrapped.slice(wrapped.length - delimiterLength); + } } else { openMarker = fmt.type === "bold" ? "**" : "_"; closeMarker = openMarker; diff --git a/src/editor/blocks/stepFieldFormatting.test.ts b/src/editor/blocks/stepFieldFormatting.test.ts index 091f317..f2bfd93 100644 --- a/src/editor/blocks/stepFieldFormatting.test.ts +++ b/src/editor/blocks/stepFieldFormatting.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { applyInlineExclusion, buildFullMarkdown, + stripInlineMarkdown, type FormattingMeta, type LinkMeta, } from "./stepField"; @@ -103,3 +104,44 @@ describe("applyInlineExclusion mutual-exclusion rules", () => { expect(result.formatting).toEqual([]); }); }); + +describe("step field code spans", () => { + const URL = "{{baseURL}}/endpoint?query_param_one=value_one"; + + it.each([ + ["`" + URL + "`"], + ["``" + URL + "``"], + ["```" + URL + "```"], + ])("reads %s as one code range without italics", (markdown) => { + expect(stripInlineMarkdown(markdown)).toEqual({ + plainText: URL, + links: [], + formatting: [{ start: 0, end: URL.length, type: "code" }], + }); + }); + + it("keeps an unmatched backtick run as text", () => { + expect(stripInlineMarkdown("a ``b` c")).toEqual({ + plainText: "a ``b` c", + links: [], + formatting: [], + }); + }); + + it("keeps multi-line code blocks multi-line", () => { + const result = stripInlineMarkdown(["```", "line 1", "line 2", "```"].join("\n")); + expect(result.plainText).toBe("line 1\nline 2"); + expect(result.formatting).toEqual([{ start: 0, end: 13, type: "code" }]); + }); + + it("wraps code containing backticks in a longer delimiter", () => { + const formatting: FormattingMeta[] = [{ start: 4, end: 7, type: "code" }]; + const markdown = buildFullMarkdown("run a`b now", [], formatting); + expect(markdown).toBe("run ``a`b`` now"); + expect(stripInlineMarkdown(markdown)).toEqual({ + plainText: "run a`b now", + links: [], + formatting, + }); + }); +}); diff --git a/src/editor/codeSpan.test.ts b/src/editor/codeSpan.test.ts new file mode 100644 index 0000000..4077efb --- /dev/null +++ b/src/editor/codeSpan.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { codeBlockFence, indexOfOutsideCode, scanCodeSpan, wrapCodeSpan } from "./codeSpan"; + +describe("scanCodeSpan", () => { + it("returns null when the index is not a backtick", () => { + expect(scanCodeSpan("abc", 0)).toBeNull(); + }); + + it.each([ + ["`foo`", "foo"], + ["`` foo ` bar ``", "foo ` bar"], + ["` `` `", "``"], + ["` `` `", " `` "], + ["` a`", " a"], + ["` `", " "], + ["`foo\\`", "foo\\"], + ["``\nfoo\nbar \nbaz\n``", "foo bar baz"], + ])("reads %j as the code %j (CommonMark examples)", (text, content) => { + expect(scanCodeSpan(text, 0)).toEqual({ kind: "span", content, end: text.length }); + }); + + it("does not close on a run of a different length", () => { + expect(scanCodeSpan("```foo``", 0)).toEqual({ kind: "literal", end: 3 }); + expect(scanCodeSpan("`foo``bar``", 0)).toEqual({ kind: "literal", end: 1 }); + }); +}); + +describe("wrapCodeSpan", () => { + it.each(["foo", "a`b", "``", "`code`", " a ", "a``b`c"])("round-trips %j", (content) => { + const wrapped = wrapCodeSpan(content); + expect(scanCodeSpan(wrapped, 0)).toEqual({ kind: "span", content, end: wrapped.length }); + }); + + it("uses the shortest delimiter that works", () => { + expect(wrapCodeSpan("foo")).toBe("`foo`"); + expect(wrapCodeSpan("a`b")).toBe("``a`b``"); + expect(wrapCodeSpan("`code`")).toBe("`` `code` ``"); + }); +}); + +describe("codeBlockFence", () => { + it("is longer than any fence inside the body", () => { + expect(codeBlockFence("plain")).toBe("```"); + expect(codeBlockFence("```js\nx\n```")).toBe("````"); + expect(codeBlockFence("inline ```not a fence```")).toBe("```"); + }); +}); + +describe("indexOfOutsideCode", () => { + it("skips markers inside code spans and escapes", () => { + expect(indexOfOutsideCode("a `**` \\** **", "**", 0)).toBe(11); + expect(indexOfOutsideCode("a `unclosed **", "**", 0)).toBe(12); + }); +}); diff --git a/src/editor/codeSpan.ts b/src/editor/codeSpan.ts new file mode 100644 index 0000000..8dd439e --- /dev/null +++ b/src/editor/codeSpan.ts @@ -0,0 +1,129 @@ +// CommonMark code span helpers (https://spec.commonmark.org/0.31.2/#code-spans), +// shared by the Markdown ↔ blocks converter and StepField so both read +// backticks the same way a standard Markdown renderer does: +// +// - a span opens with a run of N backticks and closes with the next run of +// exactly N backticks, so ``a`b`` is the code "a`b"; +// - a run with no matching closer is literal text; +// - backslash escapes do not work inside code (`a\.b` keeps its backslash); +// - line endings become spaces, and one space is stripped from each side when +// both sides have one and the content is not all spaces. + +export type CodeSpanScan = + | { kind: "span"; content: string; end: number } + | { kind: "literal"; end: number }; + +export function backtickRunLength(text: string, index: number): number { + let length = 0; + while (text[index + length] === "`") { + length += 1; + } + return length; +} + +// Finds the next run of exactly `length` backticks at or after `from`. +export function findBacktickRun(text: string, from: number, length: number): number { + let j = from; + while (j < text.length) { + if (text[j] !== "`") { + j += 1; + continue; + } + const run = backtickRunLength(text, j); + if (run === length) { + return j; + } + j += run; + } + return -1; +} + +function normalizeCodeSpanContent(raw: string): string { + const content = raw.replace(/\r?\n/g, " "); + if ( + content.length >= 2 && + content.startsWith(" ") && + content.endsWith(" ") && + content.trim().length > 0 + ) { + return content.slice(1, -1); + } + return content; +} + +// Reads the backtick run at `index`. Returns the code span it opens, or — when +// no closing run exists — a literal run the caller should keep as plain text. +// Returns null when `index` is not a backtick. +export function scanCodeSpan(text: string, index: number): CodeSpanScan | null { + const length = backtickRunLength(text, index); + if (length === 0) { + return null; + } + const contentStart = index + length; + const close = findBacktickRun(text, contentStart, length); + if (close === -1) { + return { kind: "literal", end: contentStart }; + } + return { + kind: "span", + content: normalizeCodeSpanContent(text.slice(contentStart, close)), + end: close + length, + }; +} + +function longestBacktickRun(text: string): number { + let longest = 0; + for (const match of text.match(/`+/g) ?? []) { + longest = Math.max(longest, match.length); + } + return longest; +} + +// Wraps `content` in a code span that reads back as exactly `content`: the +// delimiter is longer than any backtick run inside, and a padding space is +// added when the content touches a backtick or is itself space-padded (the +// reader strips one space from each side). +export function wrapCodeSpan(content: string): string { + const fence = "`".repeat(longestBacktickRun(content) + 1); + const needsPadding = + content.startsWith("`") || + content.endsWith("`") || + (content.length >= 2 && + content.startsWith(" ") && + content.endsWith(" ") && + content.trim().length > 0); + const pad = needsPadding ? " " : ""; + return `${fence}${pad}${content}${pad}${fence}`; +} + +// Fence for a fenced code block whose body may itself contain fence lines: +// at least three backticks and longer than any run that starts a body line. +export function codeBlockFence(body: string): string { + let longest = 0; + for (const match of body.matchAll(/^[ \t]*(`{3,})/gm)) { + longest = Math.max(longest, match[1].length); + } + return "`".repeat(Math.max(3, longest + 1)); +} + +// `text.indexOf(marker, from)` that skips code spans and backslash escapes, so +// an emphasis or link closer inside `code` is never matched — code spans bind +// tighter than every other inline construct. +export function indexOfOutsideCode(text: string, marker: string, from: number): number { + let j = from; + while (j < text.length) { + if (text[j] === "\\") { + j += 2; + continue; + } + if (text[j] === "`") { + j = scanCodeSpan(text, j)!.end; + continue; + } + if (text.startsWith(marker, j)) { + return j; + } + j += 1; + } + return -1; +} diff --git a/src/editor/customMarkdownConverter.test.ts b/src/editor/customMarkdownConverter.test.ts index 73435fc..4531f81 100644 --- a/src/editor/customMarkdownConverter.test.ts +++ b/src/editor/customMarkdownConverter.test.ts @@ -153,7 +153,7 @@ describe("blocksToMarkdown", () => { expect(out).not.toMatch(/\\[*_~<]/); }); - it("still escapes literal backticks inside inline code", () => { + it("wraps inline code containing backticks in a longer delimiter", () => { const blocks: CustomEditorBlock[] = [ { id: "p_tick", @@ -166,7 +166,9 @@ describe("blocksToMarkdown", () => { }, ]; - expect(blocksToMarkdown(blocks)).toBe("`a\\`b`"); + // Backslash escapes do not work inside code spans (CommonMark), so a + // literal backtick needs a longer delimiter instead. + expect(blocksToMarkdown(blocks)).toBe("``a`b``"); }); it("places bold markers outside leading/trailing spaces", () => { @@ -2899,18 +2901,20 @@ describe("markdownToBlocks", () => { expect(roundTripMarkdown).not.toMatch(/\n!\s*$/); }); - it("parses a single-line fenced code block", () => { + it("parses one-line ```code``` as an inline code span, not a code block", () => { + // A backtick fence's info string cannot contain backticks, so CommonMark + // (and the Markdown preview) reads this line as inline code. const markdown = "```{{baseURL}}/endpoint?query_param_one=value_one&query_param_two=value_two```"; const blocks = markdownToBlocks(markdown); expect(blocks).toEqual([ { - type: "codeBlock", - props: { language: "" }, + type: "paragraph", + props: baseProps, content: [ { type: "text", text: "{{baseURL}}/endpoint?query_param_one=value_one&query_param_two=value_two", - styles: {}, + styles: { code: true }, }, ], children: [], @@ -2918,25 +2922,22 @@ describe("markdownToBlocks", () => { ]); }); - it("parses an empty single-line fence without swallowing following lines", () => { + it("treats a lone run of six backticks as an unclosed fence", () => { const markdown = ["``````", "next line"].join("\n"); const blocks = markdownToBlocks(markdown); - expect(blocks).toHaveLength(2); - expect(blocks[0]).toEqual({ - type: "codeBlock", - props: { language: "" }, - content: undefined, - children: [], - }); - expect(blocks[1].type).toBe("paragraph"); + expect(blocks).toEqual([ + { + type: "codeBlock", + props: { language: "" }, + content: [{ type: "text", text: "next line", styles: {} }], + children: [], + }, + ]); }); - it("normalizes a single-line fenced code block to multi-line on round-trip", () => { - const markdown = "```hello world```"; - const blocks = markdownToBlocks(markdown); - expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe( - ["```", "hello world", "```"].join("\n"), - ); + it("round-trips one-line ```code``` as an inline code span", () => { + const blocks = markdownToBlocks("```hello world```"); + expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe("`hello world`"); }); it("still treats an opening fence with a language identifier as multi-line", () => { @@ -3622,3 +3623,140 @@ describe("test/suite metadata comments", () => { ); }); }); + +describe("backticks and code spans (CommonMark)", () => { + const URL = "{{baseURL}}/endpoint?query_param_one=value_one&query_param_two=value_two"; + + const inlineOf = (markdown: string) => { + const blocks = markdownToBlocks(markdown); + expect(blocks).toHaveLength(1); + expect(blocks[0].type).toBe("paragraph"); + return blocks[0].content; + }; + + it.each([ + ["single backticks", "`" + URL + "`"], + ["double backticks", "``" + URL + "``"], + ["triple backticks on one line", "```" + URL + "```"], + ])("parses %s as one inline code span and round-trips losslessly", (_label, markdown) => { + expect(inlineOf(markdown)).toEqual([ + { type: "text", text: URL, styles: { code: true } }, + ]); + const serialized = blocksToMarkdown(markdownToBlocks(markdown) as CustomEditorBlock[]); + expect(serialized).toBe("`" + URL + "`"); + }); + + it("closes a code span only with a run of the same length", () => { + expect(inlineOf("`` a ` b ``")).toEqual([ + { type: "text", text: "a ` b", styles: { code: true } }, + ]); + }); + + it("strips one padding space so a span can start and end with a backtick", () => { + const markdown = "`` `code` ``"; + expect(inlineOf(markdown)).toEqual([ + { type: "text", text: "`code`", styles: { code: true } }, + ]); + expect(blocksToMarkdown(markdownToBlocks(markdown) as CustomEditorBlock[])).toBe(markdown); + }); + + it("keeps backslashes inside code spans literal", () => { + for (const markdown of ["`a\\.b`", "`C:\\\\dir`", "`\\*not escaped\\*`"]) { + const code = markdown.slice(1, -1); + expect(inlineOf(markdown)).toEqual([ + { type: "text", text: code, styles: { code: true } }, + ]); + expect(blocksToMarkdown(markdownToBlocks(markdown) as CustomEditorBlock[])).toBe(markdown); + } + }); + + it("keeps HTML-looking text inside code spans", () => { + expect(inlineOf("`x`")).toEqual([ + { type: "text", text: "x", styles: { code: true } }, + ]); + }); + + it("treats an unmatched backtick run as literal text", () => { + expect(inlineOf("foo `unclosed")).toEqual([ + { type: "text", text: "foo `unclosed", styles: {} }, + ]); + expect(inlineOf("``foo`bar")).toEqual([ + { type: "text", text: "``foo`bar", styles: {} }, + ]); + }); + + it("lets code spans take precedence over emphasis delimiters", () => { + expect(inlineOf("**bold `x**` y**")).toEqual([ + { type: "text", text: "bold ", styles: { bold: true } }, + { type: "text", text: "x**", styles: { bold: true, code: true } }, + { type: "text", text: " y", styles: { bold: true } }, + ]); + expect(inlineOf("`a_b` and _c_")).toEqual([ + { type: "text", text: "a_b", styles: { code: true } }, + { type: "text", text: " and ", styles: {} }, + { type: "text", text: "c", styles: { italic: true } }, + ]); + }); + + it("does not treat escaped markers as formatting", () => { + expect(inlineOf("\\*not italic\\*")).toEqual([ + { type: "text", text: "*not italic*", styles: {} }, + ]); + expect(inlineOf("\\`not code\\`")).toEqual([ + { type: "text", text: "`not code`", styles: {} }, + ]); + }); + + it("does not italicize underscores inside words", () => { + expect(inlineOf("snake_case_name and query_param_one")).toEqual([ + { type: "text", text: "snake_case_name and query_param_one", styles: {} }, + ]); + expect(inlineOf("an _italic_ word")).toEqual([ + { type: "text", text: "an ", styles: {} }, + { type: "text", text: "italic", styles: { italic: true } }, + { type: "text", text: " word", styles: {} }, + ]); + expect(inlineOf("2 * 3 * 4")).toEqual([ + { type: "text", text: "2 * 3 * 4", styles: {} }, + ]); + }); + + it("closes a fence only with the same character and at least the same length", () => { + const markdown = ["````md", "```js", "x", "```", "````", "after"].join("\n"); + const blocks = markdownToBlocks(markdown); + expect(blocks[0]).toEqual({ + type: "codeBlock", + props: { language: "md" }, + content: [{ type: "text", text: ["```js", "x", "```"].join("\n"), styles: {} }], + children: [], + }); + expect(blocks[1].type).toBe("paragraph"); + expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown); + }); + + it("parses tilde fences", () => { + const blocks = markdownToBlocks(["~~~python", "print(1)", "~~~"].join("\n")); + expect(blocks).toEqual([ + { + type: "codeBlock", + props: { language: "python" }, + content: [{ type: "text", text: "print(1)", styles: {} }], + children: [], + }, + ]); + }); + + it("keeps backslashes inside code in step fields", () => { + const markdown = [ + "### Steps", + "", + "* Open ``" + URL + "`` and check `a\\.b`.", + " *Expected*: Path `C:\\\\dir` is shown\\.", + ].join("\n"); + const step = markdownToBlocks(markdown).find((b) => b.type === "testStep"); + expect(step?.props).toMatchObject({ + stepTitle: "Open ``" + URL + "`` and check `a\\.b`.", + expectedResult: "Path `C:\\\\dir` is shown.", + }); + }); +}); diff --git a/src/editor/customMarkdownConverter.ts b/src/editor/customMarkdownConverter.ts index 2f071c6..bab6e5c 100644 --- a/src/editor/customMarkdownConverter.ts +++ b/src/editor/customMarkdownConverter.ts @@ -11,6 +11,12 @@ import type { import { resolveFileDisplayUrl } from "./fileDisplayUrl"; import type { customSchema } from "./customSchema"; import { isStepsHeading } from "./blocks/step"; +import { + codeBlockFence, + indexOfOutsideCode, + scanCodeSpan, + wrapCodeSpan, +} from "./codeSpan"; // Types derived from the custom schema so the converter stays type-safe when the schema evolves. type Schema = typeof customSchema; @@ -120,6 +126,13 @@ function makeStepSegmenter(): (text: string) => StepSegment[] { let i = 0; while (i < text.length) { + // An escaped backtick outside code is literal and opens nothing. Inside + // code a backslash is itself literal, so it cannot escape the closer. + if (fence === null && text[i] === "\\" && text[i + 1] === "`") { + push(text.slice(i, i + 2), false); + i += 2; + continue; + } if (text[i] === "`") { let ticks = 0; while (text[i + ticks] === "`") ticks++; @@ -213,10 +226,24 @@ function stripLeadingFormatting(text: string): string { return result; } -function unescapeMarkdown(text: string): string { +function unescapeMarkdownText(text: string): string { return stripHtmlWrappers(text).replace(/\\([*_`~\[\]()<>.\\])/g, "$1").replace(/\\>/g, ">"); } +// Unescapes step text outside code regions only — backslashes inside code +// spans and fences are literal in Markdown, so `C:\\dir` must stay as written. +function unescapeMarkdown(text: string): string { + return makeStepSegmenter()(text) + .map((segment) => (segment.code ? segment.text : unescapeMarkdownText(segment.text))) + .join(""); +} + +// Inline (non-code) text unescape: CommonMark lets a backslash escape any ASCII +// punctuation character. +function unescapeInlineText(text: string): string { + return stripHtmlWrappers(text).replace(/\\([!-\/:-@\[-`{-~])/g, "$1"); +} + function applyTextStyles(text: string, styles: EditorStyles | undefined): string { if (!styles) { return text; @@ -230,8 +257,10 @@ function applyTextStyles(text: string, styles: EditorStyles | undefined): string const trailingWs = result.match(/(\s*)$/)?.[1] ?? ""; const trimmed = result.slice(leadingWs.length, result.length - trailingWs.length || undefined); if (!trimmed) return result; - // Code style supersedes other styles in Markdown. - return leadingWs + "`" + trimmed.replace(/`/g, "\\`") + "`" + trailingWs; + // Code style supersedes other styles in Markdown. Backslash escapes do not + // work inside code spans, so literal backticks are handled by picking a + // longer delimiter instead. + return leadingWs + wrapCodeSpan(trimmed) + trailingWs; } const wrappers: Array<{ prefix: string; suffix?: string }> = []; @@ -409,13 +438,13 @@ function serializeBlock( } case "codeBlock": { const language = (block.props as any).language || ""; - const fence = "```" + language; const body = inlineContentToPlainText(block.content); - lines.push(fence); + const fence = codeBlockFence(body); + lines.push(fence + language); if (body.length > 0) { lines.push(body); } - lines.push("```"); + lines.push(fence); return lines; } case "bulletListItem": { @@ -771,7 +800,47 @@ export function blocksToMarkdown(blocks: CustomEditorBlock[]): string { } function parseInlineMarkdown(text: string): EditorInline[] { - return parseInlineSegments(stripHtmlWrappers(text), {}); + // HTML wrappers (, ) are stripped from plain text in pushPlain, so + // code spans keep them verbatim. + return parseInlineSegments(text, {}); +} + +const WORD_CHAR_REGEX = /[\p{L}\p{N}]/u; +const ASCII_PUNCTUATION_REGEX = /[!-\/:-@\[-`{-~]/; + +function isWordChar(ch: string | undefined): boolean { + return ch !== undefined && WORD_CHAR_REGEX.test(ch); +} + +function isWhitespaceOrEdge(ch: string | undefined): boolean { + return ch === undefined || /\s/.test(ch); +} + +// Simplified CommonMark flanking rules: a delimiter run opens when followed by +// non-whitespace and closes when preceded by non-whitespace. `_` additionally +// cannot open or close inside a word, so `snake_case_name` stays plain text. +function canOpenEmphasis(text: string, index: number, length: number): boolean { + if (isWhitespaceOrEdge(text[index + length])) { + return false; + } + return text[index] === "_" ? !isWordChar(text[index - 1]) : true; +} + +function canCloseEmphasis(text: string, index: number, length: number): boolean { + if (isWhitespaceOrEdge(text[index - 1])) { + return false; + } + return text[index] === "_" ? !isWordChar(text[index + length]) : true; +} + +// Finds a closing `***`/`**`/`__`/… delimiter, skipping code spans and escapes +// and requiring non-empty content. +function findEmphasisClose(text: string, from: number, marker: string): number { + let j = indexOfOutsideCode(text, marker, from); + while (j !== -1 && (j === from || !canCloseEmphasis(text, j, marker.length))) { + j = indexOfOutsideCode(text, marker, j + 1); + } + return j; } function findItalicClose( @@ -786,15 +855,20 @@ function findItalicClose( j += 2; continue; } + if (ch === "`") { + j = scanCodeSpan(text, j)!.end; + continue; + } if ((ch === "*" || ch === "_") && text[j + 1] === ch) { - const close = text.indexOf(ch + ch, j + 2); - if (close === -1) { - return -1; + // Skip over a nested strong run; an unmatched double run falls through + // so its first character can still close the italic (`*foo**`). + const close = canOpenEmphasis(text, j, 2) ? findEmphasisClose(text, j + 2, ch + ch) : -1; + if (close !== -1) { + j = close + 2; + continue; } - j = close + 2; - continue; } - if (ch === marker) { + if (ch === marker && j > start && canCloseEmphasis(text, j, 1)) { return j; } j += 1; @@ -810,15 +884,16 @@ function parseInlineSegments( let buffer = ""; const pushPlain = () => { - if (buffer.length === 0) { + const text = unescapeInlineText(buffer); + buffer = ""; + if (text.length === 0) { return; } result.push({ type: "text", - text: unescapeMarkdown(buffer), + text, styles: { ...outerStyles } as EditorStyles, }); - buffer = ""; }; const wrap = (inner: string, add: Record) => { @@ -827,82 +902,95 @@ function parseInlineSegments( result.push(...nested); }; + // Tries a symmetric delimiter (`***`, `**`, `~~`, …) at `i`; returns the + // index after the closing delimiter, or -1 when it does not apply. + const tryDelimited = ( + i: number, + marker: string, + add: Record, + flanking: boolean, + ): number => { + if (!cleaned.startsWith(marker, i)) { + return -1; + } + if (flanking && !canOpenEmphasis(cleaned, i, marker.length)) { + return -1; + } + const from = i + marker.length; + const end = flanking + ? findEmphasisClose(cleaned, from, marker) + : indexOfOutsideCode(cleaned, marker, from); + if (end === -1 || end === from) { + return -1; + } + wrap(cleaned.slice(from, end), add); + return end + marker.length; + }; + let i = 0; while (i < cleaned.length) { - if (cleaned.startsWith("***", i)) { - const end = cleaned.indexOf("***", i + 3); - if (end !== -1) { - wrap(cleaned.slice(i + 3, end), { bold: true, italic: true }); - i = end + 3; - continue; - } - } + const ch = cleaned[i]; - if (cleaned.startsWith("___", i)) { - const end = cleaned.indexOf("___", i + 3); - if (end !== -1) { - wrap(cleaned.slice(i + 3, end), { bold: true, italic: true }); - i = end + 3; - continue; - } + // Backslash escapes: keep the pair in the buffer (pushPlain unescapes it) + // so the escaped character never acts as a delimiter. + if (ch === "\\" && ASCII_PUNCTUATION_REGEX.test(cleaned[i + 1] ?? "")) { + buffer += cleaned.slice(i, i + 2); + i += 2; + continue; } - if (cleaned.startsWith("**", i)) { - const end = cleaned.indexOf("**", i + 2); - if (end !== -1) { - wrap(cleaned.slice(i + 2, end), { bold: true }); - i = end + 2; - continue; + // Code spans bind tighter than every other inline construct and their + // content is literal — no unescaping, no emphasis. + if (ch === "`") { + const scan = scanCodeSpan(cleaned, i)!; + if (scan.kind === "span") { + pushPlain(); + result.push({ + type: "text", + text: scan.content, + styles: { ...outerStyles, code: true } as EditorStyles, + }); + } else { + buffer += cleaned.slice(i, scan.end); } + i = scan.end; + continue; } - if (cleaned.startsWith("__", i)) { - const end = cleaned.indexOf("__", i + 2); - if (end !== -1) { - wrap(cleaned.slice(i + 2, end), { bold: true }); - i = end + 2; - continue; + if (ch === "*" || ch === "_") { + let next = tryDelimited(i, ch.repeat(3), { bold: true, italic: true }, true); + if (next === -1) { + next = tryDelimited(i, ch.repeat(2), { bold: true }, true); } - } - - if (cleaned.startsWith("~~", i)) { - const end = cleaned.indexOf("~~", i + 2); - if (end !== -1) { - wrap(cleaned.slice(i + 2, end), { strike: true }); - i = end + 2; + if (next !== -1) { + i = next; continue; } } - if (cleaned.startsWith("`", i)) { - const end = cleaned.indexOf("`", i + 1); - if (end !== -1) { - pushPlain(); - const inner = cleaned.slice(i + 1, end); - result.push({ - type: "text", - text: unescapeMarkdown(inner), - styles: { ...outerStyles, code: true } as EditorStyles, - }); - i = end + 1; + if (ch === "~") { + const next = tryDelimited(i, "~~", { strike: true }, false); + if (next !== -1) { + i = next; continue; } } - if (cleaned[i] === "[") { - const endLabel = cleaned.indexOf("]", i + 1); - const startLink = cleaned.indexOf("(", endLabel + 1); - const endLink = cleaned.indexOf(")", startLink + 1); - if (endLabel !== -1 && startLink === endLabel + 1 && endLink !== -1) { + if (ch === "[") { + const endLabel = indexOfOutsideCode(cleaned, "]", i + 1); + const endLink = endLabel !== -1 && cleaned[endLabel + 1] === "(" + ? cleaned.indexOf(")", endLabel + 2) + : -1; + if (endLink !== -1) { pushPlain(); const label = cleaned.slice(i + 1, endLabel); - const href = cleaned.slice(startLink + 1, endLink); + const href = cleaned.slice(endLabel + 2, endLink); const parsedLabel = parseInlineSegments(label, {}); // Ensure link content is never undefined - if empty, add empty text const linkContent = parsedLabel.length > 0 ? parsedLabel : [{ type: "text", text: "", styles: {} }]; result.push({ type: "link", - href: unescapeMarkdown(href), + href: unescapeInlineText(href), content: linkContent, } as any); i = endLink + 1; @@ -910,9 +998,8 @@ function parseInlineSegments( } } - if (cleaned[i] === "*" || cleaned[i] === "_") { - const marker = cleaned[i] as "*" | "_"; - const end = findItalicClose(cleaned, i + 1, marker); + if ((ch === "*" || ch === "_") && canOpenEmphasis(cleaned, i, 1)) { + const end = findItalicClose(cleaned, i + 1, ch); if (end !== -1) { wrap(cleaned.slice(i + 1, end), { italic: true }); i = end + 1; @@ -927,7 +1014,7 @@ function parseInlineSegments( continue; } - buffer += cleaned[i]; + buffer += ch; i += 1; } @@ -1079,7 +1166,7 @@ function parseList( items.push({ type: "checkListItem", props: { ...cloneBaseProps(), checked }, - content: createTextContent(unescapeMarkdown(text)), + content: createTextContent(text), children: [], }); } else if (listType === "numbered") { @@ -1089,7 +1176,7 @@ function parseList( items.push({ type: "numberedListItem", props: { ...cloneBaseProps(), start }, - content: createTextContent(unescapeMarkdown(text)), + content: createTextContent(text), children: [], }); } else { @@ -1098,7 +1185,7 @@ function parseList( items.push({ type: "bulletListItem", props: cloneBaseProps(), - content: createTextContent(unescapeMarkdown(text)), + content: createTextContent(text), children: [], }); } @@ -1419,52 +1506,48 @@ function parseHeading(lines: string[], index: number): { block: CustomPartialBlo block: { type: "heading", props: { ...cloneBaseProps(), level }, - content: createTextContent(unescapeMarkdown(text)), + content: createTextContent(text), children: [], }, nextIndex: index + 1, }; } +// Opening code fence: three or more backticks or tildes, then an info string. +const CODE_FENCE_OPEN_REGEX = /^(\s*)(`{3,}|~{3,})(.*)$/; + +// Fenced code blocks per CommonMark: the closing fence uses the same character +// and is at least as long as the opening one (so a ````md block can contain +// ``` lines), and an unclosed fence runs to the end of the document. A +// backtick fence's info string cannot contain backticks, which makes a +// one-line ```code``` an inline code span rather than a block. function parseCodeBlock(lines: string[], index: number): { block: CustomPartialBlock; nextIndex: number } | null { - const trimmed = lines[index].trim(); - if (!trimmed.startsWith("```") ) { + const open = lines[index].match(CODE_FENCE_OPEN_REGEX); + if (!open) { return null; } - - const afterOpening = trimmed.slice(3); - const closeMatch = afterOpening.match(/```\s*$/); - if (closeMatch) { - const content = afterOpening.slice(0, afterOpening.length - closeMatch[0].length); - return { - block: { - type: "codeBlock", - props: { language: "" }, - content: content.length - ? [{ type: "text", text: content, styles: {} }] - : undefined, - children: [], - }, - nextIndex: index + 1, - }; + const [, indent, fence, info] = open; + if (fence[0] === "`" && info.includes("`")) { + return null; } - const language = afterOpening.trim(); + const closeRegex = new RegExp(`^\\s*${fence[0]}{${fence.length},}\\s*$`); const body: string[] = []; let next = index + 1; - while (next < lines.length && !lines[next].startsWith("```") ) { - body.push(lines[next]); + while (next < lines.length && !closeRegex.test(lines[next])) { + // Drop the opening fence's indentation from body lines, as Markdown does. + body.push(indent ? lines[next].replace(new RegExp(`^ {0,${indent.length}}`), "") : lines[next]); next += 1; } - if (next < lines.length && lines[next].startsWith("```")) { + if (next < lines.length) { next += 1; } return { block: { type: "codeBlock", - props: { language }, + props: { language: info.trim() }, content: body.length ? [{ type: "text", text: body.join("\n"), styles: {} }] : undefined, @@ -1494,7 +1577,7 @@ function parseQuote(lines: string[], index: number): { block: CustomPartialBlock block: { type: "quote", props: cloneBaseProps(), - content: createTextContent(unescapeMarkdown(collected.join("\n"))), + content: createTextContent(collected.join("\n")), children: [], }, nextIndex: next, @@ -1507,7 +1590,7 @@ function parseParagraph(lines: string[], index: number): { block: CustomPartialB block: { type: "paragraph", props: cloneBaseProps(), - content: createTextContent(unescapeMarkdown(line.trim())), + content: createTextContent(line.trim()), children: [], }, nextIndex: index + 1, From 261a5f83e2655bc413b6aeb7238ae0c93f84e902 Mon Sep 17 00:00:00 2001 From: "valentin.tsvetkov@testomat.io" Date: Tue, 6 Oct 2026 13:30:58 +0400 Subject: [PATCH 2/2] Add markdown input panel to playground Pasted Markdown is applied as the whole document, mimicking how a host app loads content on startup rather than going through the editor paste handler. Apply via button or Ctrl/Cmd+Enter. --- src/App.css | 56 +++++++++++++++++++++++++++++++++++++++++++++++++ src/App.tsx | 60 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/src/App.css b/src/App.css index d6f2b7b..672868c 100644 --- a/src/App.css +++ b/src/App.css @@ -143,6 +143,47 @@ word-break: break-word; } +.app__action--small { + padding: 0.4rem 0.9rem; + font-size: 0.9rem; +} + +.app__action:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +.app__panel .app__action--ghost { + color: #93c5fd; + background: rgba(59, 130, 246, 0.18); +} + +.app__md-input { + box-sizing: border-box; + width: 100%; + min-height: 200px; + resize: vertical; + padding: 0.75rem; + border: 1px solid rgba(148, 163, 184, 0.3); + border-radius: 0.5rem; + background: rgba(15, 23, 42, 0.6); + color: inherit; + font-family: "JetBrains Mono", "Fira Code", monospace; + font-size: 0.9rem; + line-height: 1.45; +} + +.app__md-input::placeholder { + color: rgba(148, 163, 184, 0.7); +} + +.app__md-input:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.35); +} + .app__error { margin: 0; color: #fca5a5; @@ -260,6 +301,21 @@ html.dark .app__panel { box-shadow: inset 0 0 0 1px rgba(64, 64, 64, 0.5); } +html.dark .app__md-input { + background: #0a0a0a; + border-color: #404040; +} + +html.dark .app__md-input:focus { + border-color: #6366f1; + box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.35); +} + +html.dark .app__panel .app__action--ghost { + color: #818cf8; + background: rgba(99, 102, 241, 0.15); +} + html.dark .app__panel--light { background: #262626; color: #fafafa; diff --git a/src/App.tsx b/src/App.tsx index f6fc25e..bbf1200 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -523,6 +523,8 @@ function App() { "idle", ); const [darkMode, setDarkMode] = useState(false); + const [markdownInput, setMarkdownInput] = useState(""); + const [inputError, setInputError] = useState(null); useEffect(() => { document.documentElement.classList.toggle("dark", darkMode); @@ -657,6 +659,21 @@ function App() { if (id) focusStepField(editor, id); }; + // Load the pasted Markdown as the whole document, the way a host app does on + // initial load (unlike pasting into the editor, which goes through the paste handler). + const handleApplyMarkdown = () => { + try { + const blocks = markdownToBlocks(markdownInput); + editor.replaceBlocks( + editor.document, + blocks.length > 0 ? blocks : [{ type: "paragraph" }], + ); + setInputError(null); + } catch (error) { + setInputError(error instanceof Error ? error.message : String(error)); + } + }; + const handleCopyMarkdown = async () => { if (conversionError) { return; @@ -782,6 +799,49 @@ function App() {