From 005f820ba9b72dbaa4fe20061d7659024e956c32 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:54:24 +0000 Subject: [PATCH 1/5] Bun.markdown: implement the latexMath and underline options Both options were documented, accepted by the option parser and turned on by the terminal preset, but the inline parser never produced the spans they describe: `$` was added to the mark character map and then fell through as text, and `underline` was dropped in Options::to_flags because Flags had no such field. The HTML, ANSI, React and callback renderers already handled SpanType::Latexmath, LatexmathDisplay and U. `$` runs of one or two dollars are now collected alongside the emphasis delimiters and paired up with md4c's md_analyze_dollar rules: a run can open only after whitespace or punctuation and close only before them, a closer pairs with the most recent pending opener only when both runs have the same length, and a match discards every pending opener, so math spans never nest. Delimiters inside a matched span are deactivated and pending math openers inside a resolved emphasis span are discarded, mirroring md4c's md_pop_openers. The walk emits a matched span atomically with its content as TextType::Latexmath; the HTML renderer turns newlines inside it into spaces like it does for code spans. With `underline`, `_` runs still pair up by the CommonMark rules but each matched underscore becomes one U span instead of em/strong, as in md4c's MD_FLAG_UNDERLINE. md4c's spec-latex-math.txt and spec-underline.txt, which were already checked in, now run as part of md-spec.test.ts. --- docs/runtime/markdown.mdx | 6 +- packages/bun-types/bun.d.ts | 20 +- src/md/helpers.rs | 4 + src/md/html_renderer.rs | 4 +- src/md/inlines.rs | 166 ++++++++++++++++- src/md/parser.rs | 4 +- src/md/root.rs | 1 + src/md/types.rs | 1 + test/cli/run/markdown-entrypoint.test.ts | 4 + .../integration/bun-types/fixture/markdown.ts | 27 +++ test/js/bun/md/md-edge-cases.test.ts | 173 +++++++++++++++++- test/js/bun/md/md-react.test.ts | 76 ++++++++ test/js/bun/md/md-render-callback.test.ts | 32 ++++ test/js/bun/md/md-spec.test.ts | 2 + 14 files changed, 496 insertions(+), 24 deletions(-) create mode 100644 test/integration/bun-types/fixture/markdown.ts diff --git a/docs/runtime/markdown.mdx b/docs/runtime/markdown.mdx index 3a30fb9990bd..5a3a78f703aa 100644 --- a/docs/runtime/markdown.mdx +++ b/docs/runtime/markdown.mdx @@ -61,8 +61,8 @@ All available options: | `headings` | `false` | Heading IDs and autolinks — see [Heading IDs](#heading-ids) | | `hardSoftBreaks` | `false` | Treat soft line breaks as hard breaks | | `wikiLinks` | `false` | Enable `[[wiki links]]` | -| `underline` | `false` | `__text__` renders as `` instead of `` | -| `latexMath` | `false` | Enable `$inline$` and `$$display$$` math | +| `underline` | `false` | `_` emphasis renders as `` instead of ``/`` | +| `latexMath` | `false` | `$inline$` and `$$display$$` math render as `` | | `collapseWhitespace` | `false` | Collapse whitespace in text | | `permissiveAtxHeaders` | `false` | ATX headers without space after `#` | | `noIndentedCodeBlocks` | `false` | Disable indented code blocks | @@ -357,6 +357,8 @@ You can override every HTML tag the parser produces: | `img` | `{ src, alt?, title? }` | Image (no children) | | `code` | `{ children }` | Inline code | | `del` | `{ children }` | Strikethrough (`~~text~~`) | +| `u` | `{ children }` | Underline (`_text_` with the `underline` option) | +| `math` | `{ display?, children }` | Math span (`latexMath` option). `display` is set for `$$text$$` | | `br` | `{}` | Hard line break (no children) | ### React 18 and older diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index d8c9402e2871..299f860f1939 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -1323,9 +1323,17 @@ declare module "bun" { hardSoftBreaks?: boolean; /** Enable wiki-style links (`[[target]]` or `[[target|label]]`). Default: `false`. */ wikiLinks?: boolean; - /** Enable underline syntax (`__text__` renders as `` instead of ``). Default: `false`. */ + /** + * Render underscore emphasis as underline: `_text_` produces `` + * instead of ``, and `__text__` produces nested `` instead of + * ``. Asterisk emphasis is unchanged. Default: `false`. + */ underline?: boolean; - /** Enable LaTeX math (`$inline$` and `$$display$$`). Default: `false`. */ + /** + * Enable LaTeX math spans: `$inline$` renders as `` and + * `$$display$$` as ``. The content of a math + * span is passed through verbatim, like a code span. Default: `false`. + */ latexMath?: boolean; /** Collapse whitespace in text content. Default: `false`. */ collapseWhitespace?: boolean; @@ -1410,6 +1418,10 @@ declare module "bun" { /** Image title attribute. */ title?: string; } + interface MathProps extends ChildrenProps { + /** Set for display math (`$$text$$`); absent for inline math (`$text$`). */ + display?: true; + } /** * Component overrides for `react()`. @@ -1452,7 +1464,9 @@ declare module "bun" { img?: Component; code?: Component; del?: Component; - math?: Component; + /** LaTeX math span. Only produced with the `latexMath` option. */ + math?: Component; + /** Underline span. Only produced with the `underline` option. */ u?: Component; br?: Component<{}>; } diff --git a/src/md/helpers.rs b/src/md/helpers.rs index 83cf0e3f932d..044a4c0271a1 100644 --- a/src/md/helpers.rs +++ b/src/md/helpers.rs @@ -55,6 +55,10 @@ pub(crate) fn is_unicode_punctuation(codepoint: u32) -> bool { is_unicode_punctuation_extended(codepoint) } +pub(crate) fn is_unicode_whitespace_or_punctuation(codepoint: u32) -> bool { + is_unicode_whitespace(codepoint) || is_unicode_punctuation(codepoint) +} + /// Check if byte is ASCII punctuation per CommonMark spec. #[inline] pub(crate) fn is_ascii_punctuation(c: u8) -> bool { diff --git a/src/md/html_renderer.rs b/src/md/html_renderer.rs index 7c6c0b5db5da..c45500db160b 100644 --- a/src/md/html_renderer.rs +++ b/src/md/html_renderer.rs @@ -371,8 +371,8 @@ impl<'src> HtmlRenderer<'src> { } } TextType::Entity => self.write_entity(content), - TextType::Code => { - // In code spans, newlines become spaces + TextType::Code | TextType::Latexmath => { + // In code and math spans, newlines become spaces let mut start: usize = 0; for (j, &byte) in content.iter().enumerate() { if byte == b'\n' { diff --git a/src/md/inlines.rs b/src/md/inlines.rs index 92f9cd303c25..8071970e0bd2 100644 --- a/src/md/inlines.rs +++ b/src/md/inlines.rs @@ -25,7 +25,7 @@ pub(crate) struct LabelFrame { pub struct EmphDelim { pub(crate) pos: usize, // start position in content pub(crate) count: usize, // original run length - pub(crate) emph_char: u8, // * or _ + pub(crate) emph_char: u8, // *, _, ~ or $ (math span delimiter) pub(crate) can_open: bool, pub(crate) can_close: bool, pub(crate) remaining: usize, // chars not yet consumed @@ -37,6 +37,9 @@ pub struct EmphDelim { pub(crate) close_sizes: [u8; MAX_EMPH_MATCHES], pub(crate) close_num: u8, // number of close matches pub(crate) active: bool, // false if deactivated between matched pairs + // `$` openers only: position of the closing run this opener was matched + // with. The span's content is `content[pos + count..close_pos]`. + pub(crate) close_pos: usize, } impl Default for EmphDelim { @@ -55,6 +58,7 @@ impl Default for EmphDelim { close_sizes: [0; MAX_EMPH_MATCHES], close_num: 0, active: true, + close_pos: 0, } } } @@ -331,6 +335,40 @@ impl Parser<'_> { continue; } + // Math span (`$...$` or `$$...$$`), paired up in + // resolve_math_delim. The whole span is emitted here and its + // content is verbatim, so the walk skips to the closing run. A + // run that did not open a span is text; that includes a closer + // whose opener was consumed by a construct emitted before it + // (a wiki link whose target contains a `$`). + if c == b'$' && self.flags.latex_math { + while delim_cursor < resolved.len() && resolved[delim_cursor].pos < i { + delim_cursor += 1; + } + let Some(d) = resolved + .get(delim_cursor) + .filter(|d| d.pos == i && d.open_count > 0) + else { + i += 1; + continue; + }; + if i > text_start { + self.emit_text(TextType::Normal, &content[text_start..i])?; + } + let span_type = if d.count == 2 { + SpanType::LatexmathDisplay + } else { + SpanType::Latexmath + }; + self.enter_span(span_type)?; + self.emit_text(TextType::Latexmath, &content[i + d.count..d.close_pos])?; + self.leave_span(span_type)?; + delim_cursor += 1; + i = d.close_pos + d.count; + text_start = i; + continue; + } + // Emphasis/strikethrough with * or _ or ~ — use resolved delimiters if c == b'*' || c == b'_' || (c == b'~' && self.flags.strikethrough) { // Find the corresponding resolved delimiter @@ -352,7 +390,10 @@ impl Parser<'_> { self.leave_span(SpanType::Del)?; } } else { - self.emit_emph_close_tags(&d.close_sizes[0..d.close_num as usize])?; + self.emit_emph_close_tags( + d.emph_char, + &d.close_sizes[0..d.close_num as usize], + )?; } // Emit remaining delimiter chars as text @@ -367,7 +408,10 @@ impl Parser<'_> { self.enter_span(SpanType::Del)?; } } else { - self.emit_emph_open_tags(&d.open_sizes[0..d.open_num as usize])?; + self.emit_emph_open_tags( + d.emph_char, + &d.open_sizes[0..d.open_num as usize], + )?; } delim_cursor += 1; @@ -611,12 +655,28 @@ impl Parser<'_> { self.renderer.text(text_type, content) } + /// With the underline extension, `_` runs still pair up by the CommonMark + /// emphasis rules, but every matched underscore becomes one `U` span + /// instead of an em (1) or strong (2) span, as in md4c's MD_FLAG_UNDERLINE. + fn emits_underline(&self, emph_char: u8) -> bool { + emph_char == b'_' && self.flags.underline + } + /// Emit emphasis opening tags (outermost to innermost). - pub(crate) fn emit_emph_open_tags(&mut self, sizes: &[u8]) -> crate::types::JsResult<()> { + pub(crate) fn emit_emph_open_tags( + &mut self, + emph_char: u8, + sizes: &[u8], + ) -> crate::types::JsResult<()> { + if self.emits_underline(emph_char) { + for _ in 0..sizes.iter().map(|&size| usize::from(size)).sum::() { + self.enter_span(SpanType::U)?; + } + return Ok(()); + } // First match = innermost, so emit in reverse (outermost first in HTML) - for idx in 0..sizes.len() { - let j = sizes.len() - 1 - idx; - if sizes[j] == 2 { + for &size in sizes.iter().rev() { + if size == 2 { self.enter_span(SpanType::Strong)?; } else { self.enter_span(SpanType::Em)?; @@ -627,7 +687,17 @@ impl Parser<'_> { /// Emit emphasis closing tags (innermost to outermost). /// First entry in sizes was matched first (innermost), emit in forward order. - pub(crate) fn emit_emph_close_tags(&mut self, sizes: &[u8]) -> crate::types::JsResult<()> { + pub(crate) fn emit_emph_close_tags( + &mut self, + emph_char: u8, + sizes: &[u8], + ) -> crate::types::JsResult<()> { + if self.emits_underline(emph_char) { + for _ in 0..sizes.iter().map(|&size| usize::from(size)).sum::() { + self.leave_span(SpanType::U)?; + } + return Ok(()); + } for &size in sizes { if size == 2 { self.leave_span(SpanType::Strong)?; @@ -756,6 +826,39 @@ impl Parser<'_> { }); continue; } + // Math span delimiter (1 or 2 dollars only; a longer run is text). + // md4c's rule: a run can open only after whitespace or punctuation + // and close only before them, so `$5 and $10` stays text. + if c == b'$' && self.flags.latex_math { + let run_start = i; + while i < content.len() && content[i] == b'$' { + i += 1; + } + let count = i - run_start; + if count > 2 { + continue; + } + let can_open = run_start == 0 + || helpers::is_unicode_whitespace_or_punctuation( + helpers::decode_utf8_backward(content, run_start).codepoint, + ); + let can_close = i >= content.len() + || helpers::is_unicode_whitespace_or_punctuation( + helpers::decode_utf8(content, i).codepoint, + ); + if can_open || can_close { + self.emph_delims.push(EmphDelim { + pos: run_start, + count, + emph_char: b'$', + can_open, + can_close, + remaining: count, + ..Default::default() + }); + } + continue; + } // Strikethrough delimiter (1 or 2 tildes only) if c == b'~' && self.flags.strikethrough { let run_start = i; @@ -800,10 +903,17 @@ impl Parser<'_> { }; let mut openers_bottom: [usize; 18] = [0; 18]; let mut prev_candidate: Vec = (0..len).map(|i| i.wrapping_sub(1)).collect(); + // Indices of `$` runs that may still open a math span, oldest first. + let mut math_openers: Vec = Vec::new(); // Process potential closers from left to right let mut closer_idx: usize = 0; while closer_idx < len { + if self.emph_delims[closer_idx].emph_char == b'$' { + self.resolve_math_delim(closer_idx, &mut math_openers, &mut prev_candidate); + closer_idx += 1; + continue; + } if !self.emph_delims[closer_idx].can_close || self.emph_delims[closer_idx].remaining == 0 { @@ -890,6 +1000,9 @@ impl Parser<'_> { self.emph_delims[k].active = false; k = prev_candidate[k]; } + while math_openers.last().is_some_and(|&m| m > oi) { + math_openers.pop(); + } prev_candidate[closer_idx] = oi; found_match = true; @@ -919,6 +1032,43 @@ impl Parser<'_> { } } + /// Pair `$` runs the way md4c's `md_analyze_dollar` does: a run closes the + /// most recent pending opener only if both runs have the same length + /// (`$` vs `$$`); otherwise it becomes a pending opener itself, if it may + /// open. A match discards every pending opener, so math spans never nest. + fn resolve_math_delim( + &mut self, + idx: usize, + math_openers: &mut Vec, + prev_candidate: &mut [usize], + ) { + let run = self.emph_delims[idx]; + if run.can_close { + if let Some(&oi) = math_openers.last() { + let opener = &mut self.emph_delims[oi]; + debug_assert!(opener.active && opener.remaining > 0); + if opener.count == run.count { + opener.remaining = 0; + opener.open_count = run.count; + opener.close_pos = run.pos; + self.emph_delims[idx].remaining = 0; + self.emph_delims[idx].close_count = run.count; + // The span's content is verbatim, so nothing inside it may + // still pair up with a delimiter outside of it. + for inner in &mut self.emph_delims[oi + 1..idx] { + inner.active = false; + } + prev_candidate[idx] = oi; + math_openers.clear(); + return; + } + } + } + if run.can_open { + math_openers.push(idx); + } + } + pub(crate) fn find_entity(&self, content: &[u8], start: usize) -> Option { helpers::find_entity(content, start) } diff --git a/src/md/parser.rs b/src/md/parser.rs index 251784a12fb7..76877f6b63af 100644 --- a/src/md/parser.rs +++ b/src/md/parser.rs @@ -370,8 +370,8 @@ impl<'a> Parser<'a> { // emit_text, emit_emph_open_tags, emit_emph_close_tags, // find_code_span_end, normalize_code_span_content, is_left_flanking, // is_right_flanking, can_open_emphasis, can_close_emphasis, - // collect_emphasis_delimiters, resolve_emphasis_delimiters, find_entity, - // find_html_tag + // collect_emphasis_delimiters, resolve_emphasis_delimiters, + // resolve_math_delim, find_entity, find_html_tag // // links.rs — impl Parser: // compute_bracket_matches, match_bracket, scan_bracket_close, diff --git a/src/md/root.rs b/src/md/root.rs index 0c57cd0b8925..51cbd8016399 100644 --- a/src/md/root.rs +++ b/src/md/root.rs @@ -115,6 +115,7 @@ impl Options { || self.permissive_autolinks, wiki_links: self.wiki_links, latex_math: self.latex_math, + underline: self.underline, collapse_whitespace: self.collapse_whitespace, permissive_atx_headers: self.permissive_atx_headers, no_indented_code_blocks: self.no_indented_code_blocks, diff --git a/src/md/types.rs b/src/md/types.rs index e24a2e39a2b7..c33fa08e6f73 100644 --- a/src/md/types.rs +++ b/src/md/types.rs @@ -233,6 +233,7 @@ pub struct Flags { pub(crate) strikethrough: bool, pub(crate) tasklists: bool, pub(crate) latex_math: bool, + pub(crate) underline: bool, pub(crate) wiki_links: bool, } diff --git a/test/cli/run/markdown-entrypoint.test.ts b/test/cli/run/markdown-entrypoint.test.ts index 653046cf8457..758318933abf 100644 --- a/test/cli/run/markdown-entrypoint.test.ts +++ b/test/cli/run/markdown-entrypoint.test.ts @@ -41,6 +41,10 @@ describe("bun ", () => { expect(await runMd("**bold** *italic* ~~strike~~ `code` regular\n")).toMatchSnapshot(); }); + test("renders underline and math spans (enabled by the terminal preset)", async () => { + expect(await runMd("_under_ and $x$\n")).toBe("\x1b[4munder\x1b[24m and \x1b[35m$x$\x1b[39m\x1b[0m\n"); + }); + test("renders ordered, unordered, and task lists", async () => { expect( await runMd( diff --git a/test/integration/bun-types/fixture/markdown.ts b/test/integration/bun-types/fixture/markdown.ts new file mode 100644 index 000000000000..846abce3288e --- /dev/null +++ b/test/integration/bun-types/fixture/markdown.ts @@ -0,0 +1,27 @@ +import { expectType } from "./utilities"; + +expectType(Bun.markdown.html("$x$", { latexMath: true, underline: true })).is(); +expectType(Bun.markdown.render("_x_", {}, { underline: true })).is(); + +Bun.markdown.react( + "$x$ $$y$$ _z_", + { + math: ({ display, children }) => { + expectType(display).is(); + expectType(children).is(); + return null; + }, + u: ({ children }) => { + expectType(children).is(); + return null; + }, + }, + { latexMath: true, underline: true }, +); + +// A component that only takes children still satisfies the math override. +const Plain = (props: Bun.markdown.ChildrenProps) => props.children; +Bun.markdown.react("$x$", { math: Plain, u: Plain }, { latexMath: true }); + +// @ts-expect-error +Bun.markdown.html("$x$", { latexMath: "yes" }); diff --git a/test/js/bun/md/md-edge-cases.test.ts b/test/js/bun/md/md-edge-cases.test.ts index 7fea9eea0bf3..f22c00202676 100644 --- a/test/js/bun/md/md-edge-cases.test.ts +++ b/test/js/bun/md/md-edge-cases.test.ts @@ -479,14 +479,16 @@ code }); test("latex math edge cases", () => { - const inputs = [ - "$$ $$\n", // empty display math - "$ $\n", // empty inline math - "$a$b$c$\n", // adjacent math - "$$\n\\frac{1}{2}\n$$\n", // multi-line display math + const cases = [ + ["$$ $$\n", '

\n'], + ["$ $\n", "

\n"], + // The inner dollars sit between word characters, so they can neither + // open nor close and the outer pair spans across them. + ["$a$b$c$\n", "

a$b$c

\n"], + ["$$\n\\frac{1}{2}\n$$\n", '

\\frac{1}{2}

\n'], ]; - for (const input of inputs) { - expect(typeof Markdown.html(input, { latexMath: true })).toBe("string"); + for (const [input, expected] of cases) { + expect(Markdown.html(input, { latexMath: true })).toBe(expected); } }); @@ -1382,3 +1384,160 @@ describe.concurrent("importing .md modules", () => { expect(exitCode).not.toBe(0); }); }); + +// ============================================================================ +// md4c's LaTeX math extension (`latexMath`): `$...$` and `$$...$$` become +// spans whose content is verbatim, like a code span. +// ============================================================================ + +describe("latexMath option", () => { + const math = (md: string) => Markdown.html(md, { latexMath: true }); + + test("is off by default", () => { + expect(Markdown.html("$a$ $$b$$\n")).toBe("

$a$ $$b$$

\n"); + expect(Markdown.html("$a$ $$b$$\n", { latexMath: false })).toBe("

$a$ $$b$$

\n"); + }); + + test.each([ + ["inline", "$a+b$ text\n", "

a+b text

\n"], + ["display", "$$a+b$$\n", '

a+b

\n'], + [ + "inline and display in one paragraph", + "$a$ and $$b$$\n", + '

a and b

\n', + ], + [ + "content is verbatim and HTML-escaped", + "$a_1 * b_2 & $\n", + "

a_1 * b_2 & <c>

\n", + ], + ["emphasis inside math is not emphasis", "$*a*$\n", "

*a*

\n"], + ["backslashes inside math are kept", "$\\{x\\}$\n", "

\\{x\\}

\n"], + ["a link inside math is text", "$[a](/u)$\n", "

[a](/u)

\n"], + ["a code span inside math is text", "$a `b` c$\n", "

a `b` c

\n"], + ["newlines inside math become spaces", "$a\nb$\n", "

a b

\n"], + ["multi-line display math", "$$\na\nb\n$$\n", '

a b

\n'], + ["escaped opener", "\\$a$\n", "

$a$

\n"], + ["a dollar inside a code span is not a delimiter", "`$a` b$\n", "

$a b$

\n"], + ["unclosed", "$a b\n", "

$a b

\n"], + ["prices: openers followed by a digit never close", "costs $5 and $10\n", "

costs $5 and $10

\n"], + ["opener preceded by a word character", "x$a$\n", "

x$a$

\n"], + ["closer followed by a word character", "$a$x\n", "

$a$x

\n"], + ["$$ does not close $", "$a$$\n", "

$a$$

\n"], + ["$ does not close $$", "$$a$ b\n", "

$$a$ b

\n"], + ["only the most recent pending opener is considered", "$$a $b c$$\n", "

$$a $b c$$

\n"], + [ + "no nesting: the inner pair wins and the outer opener is dropped", + "$$a $b$ c$$\n", + "

$$a b c$$

\n", + ], + ["three or more dollars are text", "$$$a$$$\n", "

$$$a$$$

\n"], + ["math inside emphasis", "*a $b$ c*\n", "

a b c

\n"], + ["emphasis closing inside a pending math span wins", "*a $b* c$\n", "

a $b c$

\n"], + ["math closing inside a pending emphasis wins", "$a *b$ c*\n", "

a *b c*

\n"], + ["strikethrough opener inside math is dropped", "$a ~~b$ c~~\n", "

a ~~b c~~

\n"], + ["math inside a link label", "[$a$](/u)\n", '

a

\n'], + ["math in image alt text contributes its content", "![$a$](/i.png)\n", '

a

\n'], + ["math in a heading", "# $E$\n", "

E

\n"], + [ + "math in a table cell", + "| a |\n| - |\n| $x$ |\n", + "\n\n\n\n\n\n\n
a
x
\n", + ], + ["delimiters next to non-ASCII punctuation", "«$a$»\n", "

«a»

\n"], + ["delimiters next to non-ASCII letters", "é$a$\n", "

é$a$

\n"], + ])("html: %s", (_name, input, expected) => { + expect(math(input)).toBe(expected); + }); + + test("snake_case option name is accepted", () => { + expect(Markdown.html("$a$\n", { latex_math: true } as any)).toBe("

a

\n"); + }); + + test("combines with underline", () => { + expect(Markdown.html("_a $b$_\n", { latexMath: true, underline: true })).toBe( + "

a b

\n", + ); + }); + + test("ansi() enables math and styles the span", () => { + expect(Markdown.ansi("x $a+b$ y\n")).toBe("x \x1b[35m$a+b$\x1b[39m y\x1b[0m\n"); + expect(Markdown.ansi("$$a$$\n", { colors: false })).toBe("$$a$$\n"); + }); + + test("math delimiter floods render in linear time", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const fill = (n, unit) => Buffer.alloc(n * unit.length, unit).toString(); + const n = 100000; + // n pending openers, then n closers: the first closer pairs with the + // last opener and every other delimiter must be dismissed in O(1). + let html = Bun.markdown.html(fill(n, "$a ") + fill(n, "a$ "), { latexMath: true }); + if (!html.includes("a a")) throw new Error("expected a math span: " + JSON.stringify(html.slice(-200))); + // One mismatched pending opener that every closer is checked against. + html = Bun.markdown.html("$$a " + fill(n, "a$ "), { latexMath: true }); + if (html.includes("$a $a $a $a a ")) throw new Error("expected emphasis: " + JSON.stringify(html.slice(0, 120))); + const ansi = Bun.markdown.ansi(fill(n / 2, "$a ") + fill(n / 2, "a$ "), { colors: false }); + if (typeof ansi !== "string" || ansi.length === 0) throw new Error("unexpected ansi output"); + console.log("DONE"); + `, + ], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + timeout: 30_000, + killSignal: "SIGKILL", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toContain("DONE"); + expect(exitCode).toBe(0); + }, 90_000); +}); + +// ============================================================================ +// md4c's underline extension (`underline`): underscores still pair up by the +// CommonMark emphasis rules, but every matched underscore is a span +// instead of / . Asterisks are unaffected. +// ============================================================================ + +describe("underline option", () => { + const underline = (md: string) => Markdown.html(md, { underline: true }); + + test("is off by default", () => { + expect(Markdown.html("_a_ __b__\n")).toBe("

a b

\n"); + expect(Markdown.html("_a_ __b__\n", { underline: false })).toBe("

a b

\n"); + }); + + test.each([ + ["single underscores", "_a_\n", "

a

\n"], + ["double underscores", "__a__\n", "

a

\n"], + ["asterisks are unaffected", "*a* **b**\n", "

a b

\n"], + ["underline inside emphasis", "*_a_*\n", "

a

\n"], + ["emphasis inside underline", "_*a*_\n", "

a

\n"], + ["underline inside strikethrough", "~~_a_~~\n", "

a

\n"], + ["partially matched run leaves the extra underscore as text", "__a_\n", "

_a

\n"], + ["intra-word underscores are text", "snake_case_name\n", "

snake_case_name

\n"], + ["unclosed underscore is text", "_a b\n", "

_a b

\n"], + ["escaped underscores are text", "\\_a\\_\n", "

_a_

\n"], + ["underline inside a link label", "[_a_](/u)\n", '

a

\n'], + [ + "underline in image alt text contributes its content", + "![_a_](/i.png)\n", + '

a

\n', + ], + ["underline in a heading", "# _a_\n", "

a

\n"], + ])("html: %s", (_name, input, expected) => { + expect(underline(input)).toBe(expected); + }); + + test("ansi() enables underline", () => { + expect(Markdown.ansi("_a_ *b*\n")).toBe("\x1b[4ma\x1b[24m \x1b[3mb\x1b[23m\x1b[0m\n"); + }); +}); diff --git a/test/js/bun/md/md-react.test.ts b/test/js/bun/md/md-react.test.ts index 855c545086c8..5bdc7fd2bf76 100644 --- a/test/js/bun/md/md-react.test.ts +++ b/test/js/bun/md/md-react.test.ts @@ -353,6 +353,40 @@ This is **bold** and *italic*. expect(del.props.children).toEqual(["deleted"]); }); + test("latexMath: inline math is a math element", () => { + const math = children("$a+b$\n", undefined, { latexMath: true })[0].props.children[0]; + expect(math.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL); + expect(math.type).toBe("math"); + expect(math.props).toEqual({ children: ["a+b"] }); + }); + + test("latexMath: display math is a math element with display: true", () => { + const math = children("$$a+b$$\n", undefined, { latexMath: true })[0].props.children[0]; + expect(math.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL); + expect(math.type).toBe("math"); + expect(math.props).toEqual({ display: true, children: ["a+b"] }); + }); + + test("latexMath: math content is a single verbatim string", () => { + const math = children("$a *b* & \\c$\n", undefined, { latexMath: true })[0].props.children[0]; + expect(math.props).toEqual({ children: ["a *b* & \\c"] }); + }); + + test("latexMath off: dollars are plain text", () => { + expect(children("$a+b$\n")[0].props.children).toEqual(["$a+b$"]); + }); + + test("underline: underscores produce u elements", () => { + const u = children("_a_\n", undefined, { underline: true })[0].props.children[0]; + expect(u.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL); + expect(u.type).toBe("u"); + expect(u.props).toEqual({ children: ["a"] }); + }); + + test("underline off: underscores produce em elements", () => { + expect(children("_a_\n")[0].props.children[0].type).toBe("em"); + }); + test("unordered list children are React elements", () => { const ul = children("- a\n- b\n")[0]; expect(ul.type).toBe("ul"); @@ -411,6 +445,14 @@ describe("Bun.markdown.react renderToString", () => { expect(reactRender("~~deleted~~\n")).toBe("

deleted

"); }); + test("inline math with latexMath", () => { + expect(reactRender("$a+b$\n", undefined, { latexMath: true })).toBe("

a+b

"); + }); + + test("underline with underline option", () => { + expect(reactRender("__a__\n", undefined, { underline: true })).toBe("

a

"); + }); + test("inline code", () => { expect(reactRender("`code`\n")).toBe("

code

"); }); @@ -602,6 +644,24 @@ describe("Bun.markdown.react component overrides", () => { expect(el.type).toBe("custom-hr"); expect(el.props).toEqual({}); }); + + test("math override receives children and display", () => { + function MathSpan({ display, children }: any) { + return React.createElement("span", { className: display ? "display" : "inline" }, ...children); + } + const [inline, _space, display] = children("$a$ $$b$$\n", { math: MathSpan }, { latexMath: true })[0].props + .children; + expect(inline.type).toBe(MathSpan); + expect(inline.props).toEqual({ children: ["a"] }); + expect(display.type).toBe(MathSpan); + expect(display.props).toEqual({ display: true, children: ["b"] }); + }); + + test("u override replaces type", () => { + const u = children("_a_\n", { u: "ins" }, { underline: true })[0].props.children[0]; + expect(u.type).toBe("ins"); + expect(u.props).toEqual({ children: ["a"] }); + }); }); describe("Bun.markdown.react renderToString with component overrides", () => { @@ -667,4 +727,20 @@ describe("Bun.markdown.react renderToString with component overrides", () => { const html = reactRender("# Title\n\nParagraph\n", { h1: H1 }); expect(html).toBe('

Title

Paragraph

'); }); + + test("custom math component", () => { + function MathSpan({ display, children }: any) { + return React.createElement("span", { className: display ? "katex-display" : "katex" }, ...children); + } + const html = reactRender("$aa<b and c

'); + }); + + test("custom underline component", () => { + function Underline({ children }: any) { + return React.createElement("span", { className: "underline" }, ...children); + } + const html = reactRender("_a_ and *b*\n", { u: Underline }, { underline: true }); + expect(html).toBe('

a and b

'); + }); }); diff --git a/test/js/bun/md/md-render-callback.test.ts b/test/js/bun/md/md-render-callback.test.ts index 7351d508360b..1db0344266b0 100644 --- a/test/js/bun/md/md-render-callback.test.ts +++ b/test/js/bun/md/md-render-callback.test.ts @@ -375,6 +375,38 @@ describe("Bun.markdown.render", () => { expect(result).toContain("[www.example.com]"); }); + test("latexMath option: math content passes through and reaches the text callback", () => { + const seen: string[] = []; + const result = Markdown.render( + "x $a *b*$ $$c$$\n", + { + paragraph: (children: string) => children, + emphasis: () => "", + text: (text: string) => { + seen.push(text); + return `[${text}]`; + }, + }, + { latexMath: true }, + ); + expect(result).toBe("[x ][a *b*][ ][c]"); + expect(seen).toEqual(["x ", "a *b*", " ", "c"]); + }); + + test("underline option: underscores no longer reach the emphasis callback", () => { + const render = (opts?: any) => + Markdown.render( + "_a_ *b*\n", + { + paragraph: (children: string) => children, + emphasis: (children: string) => `${children}`, + }, + opts, + ); + expect(render({ underline: true })).toBe("a b"); + expect(render()).toBe("a b"); + }); + test("headings option provides id in heading meta", () => { const result = Markdown.render( "## Hello World\n", diff --git a/test/js/bun/md/md-spec.test.ts b/test/js/bun/md/md-spec.test.ts index db0aa590ad64..844cdefaadf5 100644 --- a/test/js/bun/md/md-spec.test.ts +++ b/test/js/bun/md/md-spec.test.ts @@ -240,6 +240,8 @@ const specFiles = [ { name: "GFM Strikethrough", file: "spec-strikethrough.txt" }, { name: "GFM Tasklists", file: "spec-tasklists.txt" }, { name: "Permissive Autolinks", file: "spec-permissive-autolinks.txt" }, + { name: "LaTeX Math", file: "spec-latex-math.txt" }, + { name: "Underline", file: "spec-underline.txt" }, { name: "GFM", file: "spec-gfm.txt" }, { name: "Coverage", file: "coverage.txt" }, { name: "Regressions", file: "regressions.txt" }, From 6c0a5f8b1ddecff3b0c6c4230fbd5d902d25a683 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:34:47 +0000 Subject: [PATCH 2/5] md: emit underline spans from the consumed delimiter counts The em/strong emitter works from the per-run list of match sizes, which holds at most MAX_EMPH_MATCHES entries, so a long underscore run lost matches past the sixth and a run consumed by more than six closers came out unbalanced. Underline does not need the grouping into 1s and 2s: one U span per consumed underscore is exactly open_count / close_count, which also keeps the emit helpers unchanged. Also trims the comments added by the previous commit to one line each. --- src/md/inlines.rs | 81 ++++++++-------------------- src/md/parser.rs | 4 +- test/js/bun/md/md-edge-cases.test.ts | 17 ++++++ 3 files changed, 42 insertions(+), 60 deletions(-) diff --git a/src/md/inlines.rs b/src/md/inlines.rs index 8071970e0bd2..19de0602ebd7 100644 --- a/src/md/inlines.rs +++ b/src/md/inlines.rs @@ -31,15 +31,13 @@ pub struct EmphDelim { pub(crate) remaining: usize, // chars not yet consumed pub(crate) open_count: usize, // total chars consumed as opener pub(crate) close_count: usize, // total chars consumed as closer + pub(crate) close_pos: usize, // matched `$` openers only: start of the closing run // Individual match sizes in order (each is 1 for em, 2 for strong) pub(crate) open_sizes: [u8; MAX_EMPH_MATCHES], pub(crate) open_num: u8, // number of open matches pub(crate) close_sizes: [u8; MAX_EMPH_MATCHES], pub(crate) close_num: u8, // number of close matches pub(crate) active: bool, // false if deactivated between matched pairs - // `$` openers only: position of the closing run this opener was matched - // with. The span's content is `content[pos + count..close_pos]`. - pub(crate) close_pos: usize, } impl Default for EmphDelim { @@ -53,12 +51,12 @@ impl Default for EmphDelim { remaining: 0, open_count: 0, close_count: 0, + close_pos: 0, open_sizes: [0; MAX_EMPH_MATCHES], open_num: 0, close_sizes: [0; MAX_EMPH_MATCHES], close_num: 0, active: true, - close_pos: 0, } } } @@ -335,12 +333,7 @@ impl Parser<'_> { continue; } - // Math span (`$...$` or `$$...$$`), paired up in - // resolve_math_delim. The whole span is emitted here and its - // content is verbatim, so the walk skips to the closing run. A - // run that did not open a span is text; that includes a closer - // whose opener was consumed by a construct emitted before it - // (a wiki link whose target contains a `$`). + // Math span: emitted whole at its opener, so any other `$` run here is text. if c == b'$' && self.flags.latex_math { while delim_cursor < resolved.len() && resolved[delim_cursor].pos < i { delim_cursor += 1; @@ -383,17 +376,20 @@ impl Parser<'_> { let d = &resolved[delim_cursor]; let run_end = d.pos + d.count; + // md4c's MD_FLAG_UNDERLINE: every matched underscore is one U span. + let underline = d.emph_char == b'_' && self.flags.underline; // Emit closing tags first (innermost to outermost) if d.emph_char == b'~' { if d.close_count > 0 { self.leave_span(SpanType::Del)?; } + } else if underline { + for _ in 0..d.close_count { + self.leave_span(SpanType::U)?; + } } else { - self.emit_emph_close_tags( - d.emph_char, - &d.close_sizes[0..d.close_num as usize], - )?; + self.emit_emph_close_tags(&d.close_sizes[0..d.close_num as usize])?; } // Emit remaining delimiter chars as text @@ -407,11 +403,12 @@ impl Parser<'_> { if d.open_count > 0 { self.enter_span(SpanType::Del)?; } + } else if underline { + for _ in 0..d.open_count { + self.enter_span(SpanType::U)?; + } } else { - self.emit_emph_open_tags( - d.emph_char, - &d.open_sizes[0..d.open_num as usize], - )?; + self.emit_emph_open_tags(&d.open_sizes[0..d.open_num as usize])?; } delim_cursor += 1; @@ -655,28 +652,12 @@ impl Parser<'_> { self.renderer.text(text_type, content) } - /// With the underline extension, `_` runs still pair up by the CommonMark - /// emphasis rules, but every matched underscore becomes one `U` span - /// instead of an em (1) or strong (2) span, as in md4c's MD_FLAG_UNDERLINE. - fn emits_underline(&self, emph_char: u8) -> bool { - emph_char == b'_' && self.flags.underline - } - /// Emit emphasis opening tags (outermost to innermost). - pub(crate) fn emit_emph_open_tags( - &mut self, - emph_char: u8, - sizes: &[u8], - ) -> crate::types::JsResult<()> { - if self.emits_underline(emph_char) { - for _ in 0..sizes.iter().map(|&size| usize::from(size)).sum::() { - self.enter_span(SpanType::U)?; - } - return Ok(()); - } + pub(crate) fn emit_emph_open_tags(&mut self, sizes: &[u8]) -> crate::types::JsResult<()> { // First match = innermost, so emit in reverse (outermost first in HTML) - for &size in sizes.iter().rev() { - if size == 2 { + for idx in 0..sizes.len() { + let j = sizes.len() - 1 - idx; + if sizes[j] == 2 { self.enter_span(SpanType::Strong)?; } else { self.enter_span(SpanType::Em)?; @@ -687,17 +668,7 @@ impl Parser<'_> { /// Emit emphasis closing tags (innermost to outermost). /// First entry in sizes was matched first (innermost), emit in forward order. - pub(crate) fn emit_emph_close_tags( - &mut self, - emph_char: u8, - sizes: &[u8], - ) -> crate::types::JsResult<()> { - if self.emits_underline(emph_char) { - for _ in 0..sizes.iter().map(|&size| usize::from(size)).sum::() { - self.leave_span(SpanType::U)?; - } - return Ok(()); - } + pub(crate) fn emit_emph_close_tags(&mut self, sizes: &[u8]) -> crate::types::JsResult<()> { for &size in sizes { if size == 2 { self.leave_span(SpanType::Strong)?; @@ -826,9 +797,7 @@ impl Parser<'_> { }); continue; } - // Math span delimiter (1 or 2 dollars only; a longer run is text). - // md4c's rule: a run can open only after whitespace or punctuation - // and close only before them, so `$5 and $10` stays text. + // Math span delimiter, with md4c's flanking rule (it keeps `$5 and $10` as text). if c == b'$' && self.flags.latex_math { let run_start = i; while i < content.len() && content[i] == b'$' { @@ -1032,10 +1001,7 @@ impl Parser<'_> { } } - /// Pair `$` runs the way md4c's `md_analyze_dollar` does: a run closes the - /// most recent pending opener only if both runs have the same length - /// (`$` vs `$$`); otherwise it becomes a pending opener itself, if it may - /// open. A match discards every pending opener, so math spans never nest. + /// md4c's `md_analyze_dollar`: only same-length runs pair, and a match drops every pending opener. fn resolve_math_delim( &mut self, idx: usize, @@ -1053,8 +1019,7 @@ impl Parser<'_> { opener.close_pos = run.pos; self.emph_delims[idx].remaining = 0; self.emph_delims[idx].close_count = run.count; - // The span's content is verbatim, so nothing inside it may - // still pair up with a delimiter outside of it. + // Nothing inside the verbatim span may pair with a delimiter outside it. for inner in &mut self.emph_delims[oi + 1..idx] { inner.active = false; } diff --git a/src/md/parser.rs b/src/md/parser.rs index 76877f6b63af..637752cf30dc 100644 --- a/src/md/parser.rs +++ b/src/md/parser.rs @@ -370,8 +370,8 @@ impl<'a> Parser<'a> { // emit_text, emit_emph_open_tags, emit_emph_close_tags, // find_code_span_end, normalize_code_span_content, is_left_flanking, // is_right_flanking, can_open_emphasis, can_close_emphasis, - // collect_emphasis_delimiters, resolve_emphasis_delimiters, - // resolve_math_delim, find_entity, find_html_tag + // collect_emphasis_delimiters, resolve_emphasis_delimiters, find_entity, + // find_html_tag, resolve_math_delim // // links.rs — impl Parser: // compute_bracket_matches, match_bracket, scan_bracket_close, diff --git a/test/js/bun/md/md-edge-cases.test.ts b/test/js/bun/md/md-edge-cases.test.ts index f22c00202676..181e1d9e73d3 100644 --- a/test/js/bun/md/md-edge-cases.test.ts +++ b/test/js/bun/md/md-edge-cases.test.ts @@ -1537,6 +1537,23 @@ describe("underline option", () => { expect(underline(input)).toBe(expected); }); + // The em/strong emitter records at most MAX_EMPH_MATCHES (6) matches per + // delimiter run; underline is emitted from the consumed character counts + // instead, so long runs get exactly one per underscore on both sides. + const fill = (n: number, unit: string) => Buffer.alloc(n * unit.length, unit).toString(); + + test("long runs underline once per underscore", () => { + expect(underline(`${fill(14, "_")}foo${fill(14, "_")}\n`)).toBe( + `

${fill(14, "")}foo${fill(14, "")}

\n`, + ); + }); + + test("one opener consumed by many closers stays balanced", () => { + expect(underline(`${fill(14, "_")}a__ b__ c__ d__ e__ f__ g__\n`)).toBe( + `
\n`, + ); + }); + test("ansi() enables underline", () => { expect(Markdown.ansi("_a_ *b*\n")).toBe("\x1b[4ma\x1b[24m \x1b[3mb\x1b[23m\x1b[0m\n"); }); From ac01f33f97b6a0f35b5f8c0b465ae8360f619bf2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:34:13 +0000 Subject: [PATCH 3/5] md: keep the terminal preset's underline off; join lines inside ANSI code and math spans The terminal preset has listed underline since the parser landed, but the option never did anything, so ansi() and `bun file.md` have always rendered underscores as emphasis. Making the flag live would change that for every document that uses _emphasis_, with no opt-out, so the preset now says underline: false explicitly and #39560 holds the decision. A code or math span that continues on the next line reaches the ANSI renderer as one chunk with the line break inside it. write_styled emits it verbatim, so the continuation lost its list indent or quote gutter and the column count went stale. The span now becomes one line, as in the HTML renderer; this was already wrong for multi-line code spans and became reachable for math through the preset. --- packages/bun-types/bun.d.ts | 4 ++-- src/md/ansi_renderer.rs | 18 ++++++++++++++---- src/md/root.rs | 3 ++- test/cli/run/markdown-entrypoint.test.ts | 4 ++-- test/js/bun/md/md-edge-cases.test.ts | 22 ++++++++++++++++++++-- 5 files changed, 40 insertions(+), 11 deletions(-) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 299f860f1939..cb3112888ea5 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -1657,8 +1657,8 @@ declare module "bun" { * Render markdown to an ANSI-colored terminal string. * * Supports headings, lists, tables, inline styles, syntax-highlighted - * code blocks, links, images, and blockquotes. By default, enables all - * GFM extensions plus wikilinks, underline, and LaTeX math. + * code blocks, links, images, and blockquotes. Enables all GFM + * extensions plus wikilinks, autolinks, and LaTeX math spans. * * @param input The markdown string or buffer to render * @param theme Optional theme overrides diff --git a/src/md/ansi_renderer.rs b/src/md/ansi_renderer.rs index 9565f160609c..d545ed7cfa23 100644 --- a/src/md/ansi_renderer.rs +++ b/src/md/ansi_renderer.rs @@ -698,10 +698,20 @@ impl<'a> AnsiRenderer<'a> { // them at internal spaces. writeStyled with empty prefix routes // the content through the active buffer + updates col in one // pass, without the paragraph word-wrap logic. - TextType::Code => self.write_styled(b"", content), - // LaTeX math spans are atomic like .code — don't let - // writeWrapped split `$E = mc^2$` at internal spaces. - TextType::Latexmath => self.write_styled(b"", content), + TextType::Code | TextType::Latexmath => { + // A line break inside the span is a space (as in the HTML renderer). + if strings::contains_char(content, b'\n') { + let mut one_line = content.to_vec(); + for byte in &mut one_line { + if *byte == b'\n' { + *byte = b' '; + } + } + self.write_styled(b"", &one_line); + } else { + self.write_styled(b"", content); + } + } _ => self.write_content(content), } } diff --git a/src/md/root.rs b/src/md/root.rs index 51cbd8016399..744c4f666a5d 100644 --- a/src/md/root.rs +++ b/src/md/root.rs @@ -99,7 +99,8 @@ impl Options { permissive_www_autolinks: true, permissive_email_autolinks: true, wiki_links: true, - underline: true, + // Off pending #39560: on, every `_em_` in a README would render underlined. + underline: false, latex_math: true, ..Self::NONE }; diff --git a/test/cli/run/markdown-entrypoint.test.ts b/test/cli/run/markdown-entrypoint.test.ts index 758318933abf..b0e8ca3d1221 100644 --- a/test/cli/run/markdown-entrypoint.test.ts +++ b/test/cli/run/markdown-entrypoint.test.ts @@ -41,8 +41,8 @@ describe("bun ", () => { expect(await runMd("**bold** *italic* ~~strike~~ `code` regular\n")).toMatchSnapshot(); }); - test("renders underline and math spans (enabled by the terminal preset)", async () => { - expect(await runMd("_under_ and $x$\n")).toBe("\x1b[4munder\x1b[24m and \x1b[35m$x$\x1b[39m\x1b[0m\n"); + test("renders math spans; underscores stay emphasis", async () => { + expect(await runMd("_em_ and $x$\n")).toBe("\x1b[3mem\x1b[23m and \x1b[35m$x$\x1b[39m\x1b[0m\n"); }); test("renders ordered, unordered, and task lists", async () => { diff --git a/test/js/bun/md/md-edge-cases.test.ts b/test/js/bun/md/md-edge-cases.test.ts index 181e1d9e73d3..ed0c84d54a1b 100644 --- a/test/js/bun/md/md-edge-cases.test.ts +++ b/test/js/bun/md/md-edge-cases.test.ts @@ -1465,6 +1465,22 @@ describe("latexMath option", () => { expect(Markdown.ansi("$$a$$\n", { colors: false })).toBe("$$a$$\n"); }); + // A span that continues on the next line reaches the renderer as one chunk + // with the line break inside it. It has to stay one line of output, or the + // continuation loses the list indent / quote gutter and the column count. + test("ansi(): line breaks inside math and code spans become spaces", () => { + expect(Markdown.ansi("x $a +\nb$ y\n")).toBe("x \x1b[35m$a + b$\x1b[39m y\x1b[0m\n"); + const plain = (md: string, columns?: number) => Markdown.ansi(md, { colors: false, columns }); + expect(plain("- item\n $$\n x\n $$\n- next\n")).toBe(" * item $$ x $$\n * next\n"); + expect(plain("> quote $$\n> x\n> $$ end\n")).toBe("| quote $$ x $$ end\n"); + expect(plain("- The value $a +\n b$ matters\n")).toBe(" * The value $a + b$ matters\n"); + expect(plain("- item `a\n b` tail\n- next\n")).toBe(" * item a b tail\n * next\n"); + // The column count stays accurate after the span, so the wrap falls where it did for plain text. + expect(plain("$$\nabcdefghij\nklmnopqrst\n$$ one two three four five six\n", 30)).toBe( + "$$ abcdefghij klmnopqrst $$\none two three four five six\n", + ); + }); + test("math delimiter floods render in linear time", async () => { await using proc = Bun.spawn({ cmd: [ @@ -1554,7 +1570,9 @@ describe("underline option", () => { ); }); - test("ansi() enables underline", () => { - expect(Markdown.ansi("_a_ *b*\n")).toBe("\x1b[4ma\x1b[24m \x1b[3mb\x1b[23m\x1b[0m\n"); + // The terminal preset (ansi() and `bun file.md`) deliberately leaves the + // option off: underscores keep rendering as emphasis there. + test("ansi() keeps underscores as emphasis", () => { + expect(Markdown.ansi("_a_ __b__\n")).toBe("\x1b[3ma\x1b[23m \x1b[1mb\x1b[22m\x1b[0m\n"); }); }); From 9f0c2c74cf89c3fbd8a22b540829e6abb25c6991 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 21 Aug 2026 03:05:46 +0000 Subject: [PATCH 4/5] md: grow the ANSI span staging buffer through try_extend The renderers record allocation failure in OutputBuffer::oom instead of aborting, and render_to_ansi turns the flag into ParserError::OutOfMemory. The buffer that joins a multi-line code or math span into one line is output-sized staging data, so it has to grow the same way. --- src/md/ansi_renderer.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/md/ansi_renderer.rs b/src/md/ansi_renderer.rs index d545ed7cfa23..5c72cb164da6 100644 --- a/src/md/ansi_renderer.rs +++ b/src/md/ansi_renderer.rs @@ -701,7 +701,11 @@ impl<'a> AnsiRenderer<'a> { TextType::Code | TextType::Latexmath => { // A line break inside the span is a space (as in the HTML renderer). if strings::contains_char(content, b'\n') { - let mut one_line = content.to_vec(); + let mut one_line: Vec = Vec::new(); + try_extend(&mut self.out.oom, &mut one_line, content); + if self.out.oom { + return; + } for byte in &mut one_line { if *byte == b'\n' { *byte = b' '; From a1970a125b3f1d77c25cc3ad252621c4b205ed66 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 21 Aug 2026 03:50:11 +0000 Subject: [PATCH 5/5] ci: retrigger

${fill(14, "")}a b c d e f g