Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/runtime/markdown.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<u>` instead of `<strong>` |
| `latexMath` | `false` | Enable `$inline$` and `$$display$$` math |
| `underline` | `false` | `_` emphasis renders as `<u>` instead of `<em>`/`<strong>` |
| `latexMath` | `false` | `$inline$` and `$$display$$` math render as `<x-equation>` |
| `collapseWhitespace` | `false` | Collapse whitespace in text |
| `permissiveAtxHeaders` | `false` | ATX headers without space after `#` |
| `noIndentedCodeBlocks` | `false` | Disable indented code blocks |
Expand Down Expand Up @@ -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
Expand Down
24 changes: 19 additions & 5 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<u>` instead of `<strong>`). Default: `false`. */
/**
* Render underscore emphasis as underline: `_text_` produces `<u>`
* instead of `<em>`, and `__text__` produces nested `<u>` instead of
* `<strong>`. Asterisk emphasis is unchanged. Default: `false`.
*/
underline?: boolean;
/** Enable LaTeX math (`$inline$` and `$$display$$`). Default: `false`. */
/**
* Enable LaTeX math spans: `$inline$` renders as `<x-equation>` and
* `$$display$$` as `<x-equation type="display">`. 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;
Expand Down Expand Up @@ -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()`.
Expand Down Expand Up @@ -1452,7 +1464,9 @@ declare module "bun" {
img?: Component<ImageProps>;
code?: Component<ChildrenProps>;
del?: Component<ChildrenProps>;
math?: Component<ChildrenProps>;
/** LaTeX math span. Only produced with the `latexMath` option. */
math?: Component<MathProps>;
/** Underline span. Only produced with the `underline` option. */
u?: Component<ChildrenProps>;
br?: Component<{}>;
}
Expand Down Expand Up @@ -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
Expand Down
22 changes: 18 additions & 4 deletions src/md/ansi_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8> = 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),
}
}
Expand Down
4 changes: 4 additions & 0 deletions src/md/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions src/md/html_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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' {
Expand Down
117 changes: 116 additions & 1 deletion src/md/inlines.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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],
Expand Down Expand Up @@ -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
Expand All @@ -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])?;
}
Expand All @@ -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])?;
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -800,10 +872,17 @@ impl Parser<'_> {
};
let mut openers_bottom: [usize; 18] = [0; 18];
let mut prev_candidate: Vec<usize> = (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<usize> = 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
{
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<usize>,
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<usize> {
helpers::find_entity(content, start)
}
Expand Down
2 changes: 1 addition & 1 deletion src/md/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion src/md/root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/md/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down
4 changes: 4 additions & 0 deletions test/cli/run/markdown-entrypoint.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ describe("bun <file.md>", () => {
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");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
});

test("renders ordered, unordered, and task lists", async () => {
expect(
await runMd(
Expand Down
27 changes: 27 additions & 0 deletions test/integration/bun-types/fixture/markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { expectType } from "./utilities";

expectType(Bun.markdown.html("$x$", { latexMath: true, underline: true })).is<string>();
expectType(Bun.markdown.render("_x_", {}, { underline: true })).is<string>();

Bun.markdown.react(
"$x$ $$y$$ _z_",
{
math: ({ display, children }) => {
expectType(display).is<true | undefined>();
expectType(children).is<Bun.markdown.ChildrenProps["children"]>();
return null;
},
u: ({ children }) => {
expectType(children).is<Bun.markdown.ChildrenProps["children"]>();
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" });
Loading
Loading