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", "![$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"); + }); + + // 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", + "![_a_](/i.png)\n", + '

a

\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("

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" },