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..cb3112888ea5 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<{}>;
}
@@ -1643,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..5c72cb164da6 100644
--- a/src/md/ansi_renderer.rs
+++ b/src/md/ansi_renderer.rs
@@ -698,10 +698,24 @@ 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: 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' ';
+ }
+ }
+ self.write_styled(b"", &one_line);
+ } else {
+ self.write_styled(b"", content);
+ }
+ }
_ => self.write_content(content),
}
}
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..19de0602ebd7 100644
--- a/src/md/inlines.rs
+++ b/src/md/inlines.rs
@@ -25,12 +25,13 @@ 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
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
@@ -50,6 +51,7 @@ 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],
@@ -331,6 +333,35 @@ impl Parser<'_> {
continue;
}
+ // 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;
+ }
+ 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
@@ -345,12 +376,18 @@ 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.close_sizes[0..d.close_num as usize])?;
}
@@ -366,6 +403,10 @@ 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.open_sizes[0..d.open_num as usize])?;
}
@@ -756,6 +797,37 @@ impl Parser<'_> {
});
continue;
}
+ // 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'$' {
+ 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 +872,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 +969,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 +1001,39 @@ impl Parser<'_> {
}
}
+ /// 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,
+ 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;
+ // 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;
+ }
+ 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..637752cf30dc 100644
--- a/src/md/parser.rs
+++ b/src/md/parser.rs
@@ -371,7 +371,7 @@ impl<'a> Parser<'a> {
// 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
+ // find_html_tag, resolve_math_delim
//
// 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..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
};
@@ -115,6 +116,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..b0e8ca3d1221 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 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 () => {
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..ed0c84d54a1b 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,195 @@ 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", "\n", '
\n'],
+ ["math in a heading", "# $E$\n", "E
\n"],
+ [
+ "math in a table cell",
+ "| a |\n| - |\n| $x$ |\n",
+ "\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");
+ });
+
+ // 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: [
+ 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",
+ "\n",
+ '
\n',
+ ],
+ ["underline in a heading", "# _a_\n", "a
\n"],
+ ])("html: %s", (_name, input, expected) => {
+ 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(
+ `${fill(14, "")}a
b c d e f g
\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");
+ });
+});
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("");
+ });
+
+ 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" },