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
13 changes: 12 additions & 1 deletion docs/runtime/markdown.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,14 @@ The `listItem` callback receives everything needed to render markers directly:
| `strikethrough` | — | Strikethrough (`~~text~~`) |
| `text` | — | Plain text content |

#### Link and image meta

`href`, `src` and `title` are the values that the document means, not its source text:

- Backslash escapes and character references are resolved. `[a](/x\_y?a=1&b=2 "R&D")` gives `href: "/x_y?a=1&b=2"` and `title: "R&D"`.
- An e-mail autolink has the `mailto:` scheme, and a `www.` autolink has `http://`. `<me@example.com>` gives `href: "mailto:me@example.com"`.
- The values are not percent-encoded and not HTML-escaped. Escape them for the format that you write.

### Examples

#### Custom HTML with classes
Expand Down Expand Up @@ -261,11 +269,12 @@ Pass parser options as a separate third argument:
const result = Bun.markdown.render(
"Visit www.example.com",
{
link: (children, { href }) => `[${children}](${href})`,
link: (children, { href }) => `<a href="${Bun.escapeHTML(href)}">${children}</a>`,
paragraph: children => children,
},
{ autolinks: true },
);
// 'Visit <a href="http://www.example.com">www.example.com</a>'
```

---
Expand Down Expand Up @@ -359,6 +368,8 @@ You can override every HTML tag the parser produces:
| `del` | `{ children }` | Strikethrough (`~~text~~`) |
| `br` | `{}` | Hard line break (no children) |

`href`, `src` and `title` are resolved values, like the [meta of `render()`](#link-and-image-meta).

### React 18 and older

By default, elements use `Symbol.for('react.transitional.element')` as the `$$typeof` symbol. For React 18 and older, pass `reactVersion: 18` in the options (third argument):
Expand Down
26 changes: 17 additions & 9 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1683,17 +1683,21 @@ declare module "bun" {
align?: "left" | "center" | "right";
}
interface LinkProps extends ChildrenProps {
/** Link URL. */
/**
* Link URL, with backslash escapes and character references resolved:
* `[a](/x\_y?a=1&amp;b=2)` gives `"/x_y?a=1&b=2"`. An e-mail or `www.`
* autolink has its scheme (`mailto:`, `http://`). Not percent-encoded.
*/
href: string;
/** Link title attribute. */
/** Link title attribute, with backslash escapes and character references resolved. */
title?: string;
}
interface ImageProps {
/** Image URL. */
/** Image URL, with backslash escapes and character references resolved. Not percent-encoded. */
src: string;
/** Alt text. */
alt?: string;
/** Image title attribute. */
/** Image title attribute, with backslash escapes and character references resolved. */
title?: string;
}

Expand Down Expand Up @@ -1789,17 +1793,21 @@ declare module "bun" {

/** Meta passed to the `link` callback. */
interface LinkMeta {
/** Link URL. */
/**
* Link URL, with backslash escapes and character references resolved:
* `[a](/x\_y?a=1&amp;b=2)` gives `"/x_y?a=1&b=2"`. An e-mail or `www.`
* autolink has its scheme (`mailto:`, `http://`). Not percent-encoded.
*/
href: string;
/** Link title attribute. */
/** Link title attribute, with backslash escapes and character references resolved. */
title?: string;
}

/** Meta passed to the `image` callback. */
interface ImageMeta {
/** Image URL. */
/** Image URL, with backslash escapes and character references resolved. Not percent-encoded. */
src: string;
/** Image title attribute. */
/** Image title attribute, with backslash escapes and character references resolved. */
title?: string;
}

Expand Down Expand Up @@ -1995,7 +2003,7 @@ declare module "bun" {
*
* // With parser options as third argument
* const text = Bun.markdown.render("Visit www.example.com", {
* link: (children, { href }) => `[${children}](${href})`,
* link: (children, { href }) => `<a href="${Bun.escapeHTML(href)}">${children}</a>`,
* paragraph: (children) => children,
* }, { autolinks: true });
* ```
Expand Down
16 changes: 8 additions & 8 deletions src/format/markdown/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1881,20 +1881,20 @@ impl RendererImpl for Builder<'_> {
let url = if detail.autolink_email {
self.tree.owned(|out| {
out.extend_from_slice(b"mailto:");
out.extend_from_slice(detail.href);
out.extend_from_slice(detail.href_as_written());
})
} else if detail.autolink {
let nul = self.nul;
self.tree
.owned(|out| push_without_nul(detail.href, nul, out))
} else if detail.href.is_empty() {
.owned(|out| push_without_nul(detail.href_as_written(), nul, out))
} else if detail.href_as_written().is_empty() {
Str::EMPTY
} else {
self.decoded(detail.href)
self.decoded(detail.href_as_written())
};
let title = match detail.title.is_empty() {
let title = match detail.title_as_written().is_empty() {
true => Str::NO,
false => self.title(detail.title),
false => self.title(detail.title_as_written()),
};
if let Some(node) = self.tree.get_mut(node) {
(node.value, node.second) = (url, title);
Expand All @@ -1910,9 +1910,9 @@ impl RendererImpl for Builder<'_> {
}
}
Kind::FootnoteReference => {
let normalized = normalize_identifier(detail.href);
let normalized = normalize_identifier(detail.href_as_written());
let (label, identifier) = (
self.decoded(detail.href),
self.decoded(detail.href_as_written()),
self.tree.owned(|out| push_lowercase(&normalized, out)),
);
if let Some(node) = self.tree.get_mut(node) {
Expand Down
90 changes: 58 additions & 32 deletions src/md/ansi_renderer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,15 +81,11 @@ impl RendererImpl for ImageUrlCollector {
if span_type != SpanType::Img {
return Ok(());
}
if detail.href.is_empty() {
if detail.href_as_written().is_empty() {
return Ok(());
}
// detail.href is a slice into the parser's reusable buffer, which
// is freed when renderWithRenderer returns (p.deinit). Dupe it so
// callers can safely read collector.urls after rendering finishes.
let mut scratch: Vec<u8> = Vec::new();
let owned = Box::<[u8]>::from(sanitize_source_text(detail.href, &mut scratch));
self.urls.push(owned);
// `AnsiRenderer::emit_image` looks the download up by these bytes.
self.urls.push(sanitized_address(&detail.href()));
Ok(())
}
}
Expand Down Expand Up @@ -118,6 +114,8 @@ struct AnsiRenderer<'a> {
image_alt: Vec<u8>,
/// Saved image src URL for when the image span closes (owned).
image_src: Option<Box<[u8]>>,
/// That src as the document writes it, if `image_src` is not that: a file can have that name.
image_src_written: Option<Box<[u8]>>,
/// Saved image title (rendered after alt, owned).
image_title: Option<Box<[u8]>>,
/// Active paragraph-level wrapping column usage. Tracks visible chars
Expand Down Expand Up @@ -280,6 +278,7 @@ impl<'a> AnsiRenderer<'a> {
image_depth: 0,
image_alt: Vec::new(),
image_src: None,
image_src_written: None,
image_title: None,
col: 0,
soft_break: None,
Expand Down Expand Up @@ -590,14 +589,13 @@ impl<'a> AnsiRenderer<'a> {
SpanType::Img => {
self.image_depth += 1;
if self.image_depth == 1 {
let mut src_scratch: Vec<u8> = Vec::new();
self.image_src = Some(Box::<[u8]>::from(sanitize_source_text(
detail.href,
&mut src_scratch,
)));
let src = detail.href();
self.image_src_written = matches!(src, std::borrow::Cow::Owned(_))
.then(|| sanitized_address(detail.href_as_written()));
self.image_src = Some(sanitized_address(&src));
let mut title_scratch: Vec<u8> = Vec::new();
self.image_title = Some(Box::<[u8]>::from(sanitize_source_text(
detail.title,
&detail.title(),
&mut title_scratch,
)));
self.image_alt.clear();
Expand Down Expand Up @@ -661,6 +659,7 @@ impl<'a> AnsiRenderer<'a> {
if self.image_depth == 1 {
self.emit_image();
self.image_src = None;
self.image_src_written = None;
self.image_title = None;
self.image_alt.clear();
}
Expand Down Expand Up @@ -1945,7 +1944,10 @@ impl<'a> AnsiRenderer<'a> {
}
}
}
if let Some(abs_path) = resolve_local_image_path(s, self.theme.image_base_dir) {
let base_dir = self.theme.image_base_dir;
let found = resolve_local_image_path(s, base_dir)
.or_else(|| resolve_local_image_path(self.image_src_written.as_deref()?, base_dir));
if let Some(abs_path) = found {
self.emit_kitty_image_file(&abs_path);
self.image_depth = saved_depth;
self.image_alt = alt;
Expand Down Expand Up @@ -1975,8 +1977,13 @@ impl<'a> AnsiRenderer<'a> {
&& self.link.is_none()
&& !src.as_deref().unwrap().starts_with(b"data:");
if link_ok {
let mut scratch: Vec<u8> = Vec::new();
self.write_raw_no_color(b"\x1b]8;;");
self.write_raw_no_color(src.as_deref().unwrap());
self.write_raw_no_color(strip_controls(
src.as_deref().unwrap(),
&mut scratch,
Sanitize::AddressInSequence,
));
self.write_raw_no_color(b"\x1b\\");
}
let img_marker: &[u8] = if self.theme.colors {
Expand Down Expand Up @@ -2040,8 +2047,13 @@ impl<'a> AnsiRenderer<'a> {
/// `base64_payload` is already the base64 body of a `data:image/png`
/// URL, so we forward it as-is — no temp file, no re-encoding.
fn emit_kitty_image_direct(&mut self, base64_payload: &[u8]) {
let mut scratch: Vec<u8> = Vec::new();
self.write_raw_no_color(b"\x1b_Ga=T,t=d,f=100,q=2;");
self.write_raw_no_color(base64_payload);
self.write_raw_no_color(strip_controls(
base64_payload,
&mut scratch,
Sanitize::AddressInSequence,
));
self.write_raw_no_color(b"\x1b\\");
self.write_raw(b"\n");
self.col = 0;
Expand Down Expand Up @@ -2361,21 +2373,39 @@ fn visible_index_at(s: &[u8], max_cols: usize) -> usize {
}

fn sanitize_source_text<'b>(bytes: &'b [u8], scratch: &'b mut Vec<u8>) -> &'b [u8] {
fn is_disallowed(c: u8) -> bool {
(c < 0x20 && c != b'\n' && c != b'\t') || c == 0x7f
}
strip_controls(bytes, scratch, Sanitize::Text)
}

fn sanitized_address(address: &[u8]) -> Box<[u8]> {
let mut scratch: Vec<u8> = Vec::new();
Box::from(strip_controls(address, &mut scratch, Sanitize::Address))
}

#[derive(Copy, Clone, PartialEq, Eq)]
enum Sanitize {
/// Text of the document. An ESC goes with the escape sequence that it starts.
Text,
/// Where a link or an image goes. Only the control bytes go: what follows one is part of the address.
Address,
/// An address in an OSC 8 sequence or a Kitty payload. A tab or a line break would break it, so they go too.
AddressInSequence,
}

fn strip_controls<'b>(bytes: &'b [u8], scratch: &'b mut Vec<u8>, how: Sanitize) -> &'b [u8] {
let keeps_tab_and_line_break = how != Sanitize::AddressInSequence;
let is_disallowed = |c: u8| {
(c < 0x20 && !(keeps_tab_and_line_break && (c == b'\n' || c == b'\t'))) || c == 0x7f
};
fn is_utf8_c1(bytes: &[u8], i: usize) -> bool {
bytes[i] == 0xC2 && i + 1 < bytes.len() && (0x80..=0x9F).contains(&bytes[i + 1])
}
fn needs_strip(bytes: &[u8], i: usize) -> bool {
is_disallowed(bytes[i]) || is_utf8_c1(bytes, i)
}
let needs_strip = |bytes: &[u8], i: usize| is_disallowed(bytes[i]) || is_utf8_c1(bytes, i);
if !(0..bytes.len()).any(|i| needs_strip(bytes, i)) {
return bytes;
}
let mut i: usize = 0;
while i < bytes.len() {
if bytes[i] == 0x1b {
if bytes[i] == 0x1b && how == Sanitize::Text {
i += 1;
if i < bytes.len() && bytes[i] == b'[' {
i += 1;
Expand Down Expand Up @@ -2459,16 +2489,12 @@ fn extract_language(src_text: &[u8], info_beg: u32) -> &[u8] {
/// Build the final href string with autolink prefixes (mailto:, http://).
/// Caller owns the returned memory.
fn resolve_href(detail: &SpanDetail) -> Box<[u8]> {
let mut buf: Vec<u8> = Vec::new();
if detail.autolink_email {
buf.extend_from_slice(b"mailto:");
}
if detail.autolink_www {
buf.extend_from_slice(b"http://");
}
let mut scratch: Vec<u8> = Vec::new();
buf.extend_from_slice(sanitize_source_text(detail.href, &mut scratch));
buf.into_boxed_slice()
Box::from(strip_controls(
&detail.href(),
&mut scratch,
Sanitize::AddressInSequence,
))
}

// ========================================
Expand Down
2 changes: 2 additions & 0 deletions src/md/blocks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1151,6 +1151,7 @@ impl Parser<'_> {
let raw_label: Box<[u8]> = Box::from(result.label);
let dest_dupe: Box<[u8]> = Box::from(result.dest);
let title_dupe: Box<[u8]> = Box::from(result.title);
let has_escape = result.has_escape;
let end_pos = result.end_pos;

let norm_label = self.normalize_label(&raw_label);
Expand All @@ -1165,6 +1166,7 @@ impl Parser<'_> {
self.ref_defs.push(crate::ref_defs::RefDef {
dest: dest_dupe,
title: title_dupe,
has_escape,
});
}

Expand Down
41 changes: 41 additions & 0 deletions src/md/helpers.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::borrow::Cow;

use super::entity as entity_mod;
use super::types::{OFF, TextType};

Expand Down Expand Up @@ -438,6 +440,45 @@ pub fn decode_entity_to_utf8<'a>(entity_text: &[u8], out: &'a mut [u8; 8]) -> Op
None
}

/// What a link destination or a link title means: `\*` is `*` and `&amp;` is `&`.
pub(crate) fn resolve_escapes(raw: &[u8]) -> Cow<'_, [u8]> {
let mut resolved: Vec<u8> = Vec::new();
// `raw[..done]` is in `resolved`.
let mut done = 0;
let mut from = 0;
while let Some(found) = bun_core::strings::index_of_any(&raw[from..], b"\\&") {
let at = from + found;
let mut entity = [0u8; 8];
let resolution = match raw[at] {
b'\\' => raw
.get(at + 1)
.filter(|&&escaped| is_ascii_punctuation(escaped))
.map(|escaped| (core::slice::from_ref(escaped), at + 2)),
_ => match find_entity(raw, at) {
Some(end) => decode_entity_to_utf8(&raw[at..end], &mut entity)
.map(|stands_for| (stands_for, end)),
None => None,
},
};
let Some((stands_for, end)) = resolution else {
from = at + 1;
continue;
};
if done == 0 {
resolved.reserve(raw.len());
}
resolved.extend_from_slice(&raw[done..at]);
resolved.extend_from_slice(stands_for);
done = end;
from = end;
}
if done == 0 {
return Cow::Borrowed(raw);
}
resolved.extend_from_slice(&raw[done..]);
Cow::Owned(resolved)
}

/// Generate a GitHub-compatible slug from text content.
/// Modifies text_buf in-place. Uses slug_counts for -N deduplication.
pub(crate) fn generate_slug<'a>(
Expand Down
Loading
Loading