diff --git a/docs/runtime/markdown.mdx b/docs/runtime/markdown.mdx index 3a30fb9990bd..9dc848fff25d 100644 --- a/docs/runtime/markdown.mdx +++ b/docs/runtime/markdown.mdx @@ -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://`. `` 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 @@ -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 }) => `${children}`, paragraph: children => children, }, { autolinks: true }, ); +// 'Visit www.example.com' ``` --- @@ -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): diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 2ded9f1ad305..634f6e9d0530 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -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&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; } @@ -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&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; } @@ -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 }) => `${children}`, * paragraph: (children) => children, * }, { autolinks: true }); * ``` diff --git a/src/format/markdown/parse.rs b/src/format/markdown/parse.rs index adf951a2070d..48b4a70b35eb 100644 --- a/src/format/markdown/parse.rs +++ b/src/format/markdown/parse.rs @@ -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); @@ -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) { diff --git a/src/md/ansi_renderer.rs b/src/md/ansi_renderer.rs index 147e7de37960..e9f072ef02fd 100644 --- a/src/md/ansi_renderer.rs +++ b/src/md/ansi_renderer.rs @@ -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 = 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(()) } } @@ -118,6 +114,8 @@ struct AnsiRenderer<'a> { image_alt: Vec, /// Saved image src URL for when the image span closes (owned). image_src: Option>, + /// That src as the document writes it, if `image_src` is not that: a file can have that name. + image_src_written: Option>, /// Saved image title (rendered after alt, owned). image_title: Option>, /// Active paragraph-level wrapping column usage. Tracks visible chars @@ -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, @@ -590,14 +589,13 @@ impl<'a> AnsiRenderer<'a> { SpanType::Img => { self.image_depth += 1; if self.image_depth == 1 { - let mut src_scratch: Vec = 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 = Vec::new(); self.image_title = Some(Box::<[u8]>::from(sanitize_source_text( - detail.title, + &detail.title(), &mut title_scratch, ))); self.image_alt.clear(); @@ -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(); } @@ -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; @@ -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 = 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 { @@ -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 = 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; @@ -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) -> &'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 = 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, 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; @@ -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 = 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 = 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, + )) } // ======================================== diff --git a/src/md/blocks.rs b/src/md/blocks.rs index c383c3f0b993..c74fe8da7fa4 100644 --- a/src/md/blocks.rs +++ b/src/md/blocks.rs @@ -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); @@ -1165,6 +1166,7 @@ impl Parser<'_> { self.ref_defs.push(crate::ref_defs::RefDef { dest: dest_dupe, title: title_dupe, + has_escape, }); } diff --git a/src/md/helpers.rs b/src/md/helpers.rs index f67cae403145..f8737dc351ea 100644 --- a/src/md/helpers.rs +++ b/src/md/helpers.rs @@ -1,3 +1,5 @@ +use std::borrow::Cow; + use super::entity as entity_mod; use super::types::{OFF, TextType}; @@ -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 `&` is `&`. +pub(crate) fn resolve_escapes(raw: &[u8]) -> Cow<'_, [u8]> { + let mut resolved: Vec = 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>( diff --git a/src/md/html_renderer.rs b/src/md/html_renderer.rs index 45271496b521..3fecd0f63664 100644 --- a/src/md/html_renderer.rs +++ b/src/md/html_renderer.rs @@ -273,38 +273,38 @@ impl<'src> HtmlRenderer<'src> { if detail.autolink_www { self.write(b"http://"); } - self.write_html_escaped(detail.href); + self.write_html_escaped(detail.href_as_written()); } else if detail.autolink { // Standard autolinks: percent-encode only, no entity/escape processing if detail.autolink_email { self.write(b"mailto:"); } - self.write_url_escaped(detail.href); + self.write_url_escaped(detail.href_as_written()); } else { // Regular links: full entity/escape processing if detail.autolink_email { self.write(b"mailto:"); } - self.write_url_with_escapes(detail.href); + self.write_url_with_escapes(detail.href_as_written()); } self.write(b"\""); - if !detail.title.is_empty() { + if !detail.title_as_written().is_empty() { self.write(b" title=\""); - self.write_title_with_escapes(detail.title); + self.write_title_with_escapes(detail.title_as_written()); self.write(b"\""); } self.write(b">"); } SpanType::Img => { - self.saved_img_title = Box::from(detail.title); + self.saved_img_title = Box::from(detail.title_as_written()); self.write(b"\""); { self.write(b""); } } diff --git a/src/md/inlines.rs b/src/md/inlines.rs index 6f84f03512c8..5b07e5f661bc 100644 --- a/src/md/inlines.rs +++ b/src/md/inlines.rs @@ -648,13 +648,11 @@ impl Parser<'_> { self.at(base + a.beg, base + a.beg); self.renderer.enter_span( SpanType::A, - crate::types::SpanDetail { - href: link_text, - permissive_autolink: true, - autolink_email: c == b'@', - autolink_www: c == b'.', - ..Default::default() - }, + crate::types::SpanDetail::permissive_autolink( + link_text, + c == b'@', + c == b'.', + ), )?; self.at(base + a.beg, base + a.end); self.emit_text(TextType::Normal, link_text)?; diff --git a/src/md/links.rs b/src/md/links.rs index 7dc27ebbc963..fae111b4c886 100644 --- a/src/md/links.rs +++ b/src/md/links.rs @@ -64,12 +64,15 @@ pub(crate) struct ParsedDest<'a> { pub(crate) dest: &'a [u8], /// Position just past the destination (past the `>` of the angle-bracket form). pub(crate) end_pos: usize, + /// `dest` holds a `\` or a `&`. + pub(crate) has_escape: bool, } /// CommonMark §6.3 link destination, shared by inline links, the link lookahead and reference definitions. pub(crate) fn scan_link_destination(text: &[u8], start: usize) -> Option> { let escapes_next = |p: usize| p + 1 < text.len() && helpers::is_ascii_punctuation(text[p + 1]); let mut p = start; + let mut has_escape = false; if p < text.len() && text[p] == b'<' { p += 1; @@ -80,10 +83,18 @@ pub(crate) fn scan_link_destination(text: &[u8], start: usize) -> Option return None, - b'\\' if escapes_next(p) => p += 2, + b'\\' => { + has_escape = true; + p += if escapes_next(p) { 2 } else { 1 }; + } + b'&' => { + has_escape = true; + p += 1; + } _ => p += 1, } } @@ -93,10 +104,14 @@ pub(crate) fn scan_link_destination(text: &[u8], start: usize) -> Option { - p += 2; - continue; + b'\\' => { + has_escape = true; + if escapes_next(p) { + p += 2; + continue; + } } + b'&' => has_escape = true, b'(' => { paren_depth += 1; if paren_depth > MAX_LINK_DEST_PAREN_DEPTH { @@ -120,6 +135,7 @@ pub(crate) fn scan_link_destination(text: &[u8], start: usize) -> Option { title: &[u8], is_image: bool, reference: Reference, + // `dest` or `title` holds a `\` or a `&`, as the scan that cut them saw. + has_escape: bool, // Where the `[` is in the block's inline content. bracket: usize, ) -> Result { if self.image_nesting_level > 0 { // Inside image alt text: emit only text, no HTML tags - Ok(LabelLeave::AltText) - } else if is_image { + return Ok(LabelLeave::AltText); + } + let attrs = SpanAttrs::link(dest, title, reference, has_escape); + if is_image { self.at(bracket - 1, bracket + 1); - self.renderer.enter_span( - Span::Img, - SpanAttrs { - href: dest, - title, - reference, - ..Default::default() - }, - )?; + self.renderer.enter_span(Span::Img, attrs)?; self.image_nesting_level += 1; Ok(LabelLeave::Image) } else { self.at(bracket, bracket + 1); - self.renderer.enter_span( - Span::A, - SpanAttrs { - href: dest, - title, - reference, - ..Default::default() - }, - )?; + self.renderer.enter_span(Span::A, attrs)?; self.link_nesting_level += 1; Ok(LabelLeave::Link) } @@ -373,9 +377,11 @@ impl Parser<'_> { pos += 1; } + let mut has_escape = false; let dest: &[u8] = match scan_link_destination(content, pos) { Some(parsed) => { pos = parsed.end_pos; + has_escape = parsed.has_escape; parsed.dest } None => { @@ -410,10 +416,16 @@ impl Parser<'_> { pos += 1; let title_start = pos; let mut title_valid = true; + let mut title_has_escape = false; while pos < content.len() && content[pos] != close_char { - if content[pos] == b'\\' && pos + 1 < content.len() { - pos += 2; - continue; + if content[pos] == b'\\' { + title_has_escape = true; + if pos + 1 < content.len() { + pos += 2; + continue; + } + } else if content[pos] == b'&' { + title_has_escape = true; } // A ()-delimited title may not contain an unescaped '(' if close_char == b')' @@ -426,6 +438,7 @@ impl Parser<'_> { pos += 1; } if title_valid { + has_escape |= title_has_escape; title = &content[title_start..pos]; if pos < content.len() { pos += 1; // skip closing quote @@ -448,8 +461,14 @@ impl Parser<'_> { if pos < content.len() && content[pos] == b')' { pos += 1; - let leave = - self.enter_label_span(dest, title, is_image, Reference::None, base + start)?; + let leave = self.enter_label_span( + dest, + title, + is_image, + Reference::None, + has_escape, + base + start, + )?; return Ok(Some(LabelParse { label_start: start + 1, label_end, @@ -490,11 +509,18 @@ impl Parser<'_> { // lookup_ref_def is dropped before &mut self calls. let dest: Box<[u8]> = Box::from(&ref_def.dest[..]); let title: Box<[u8]> = Box::from(&ref_def.title[..]); + let has_escape = ref_def.has_escape; if !self.charge_ref_def_output(dest.len(), title.len()) { return Ok(None); } - let leave = - self.enter_label_span(&dest, &title, is_image, reference, base + start)?; + let leave = self.enter_label_span( + &dest, + &title, + is_image, + reference, + has_escape, + base + start, + )?; return Ok(Some(LabelParse { label_start: start + 1, label_end, @@ -519,6 +545,7 @@ impl Parser<'_> { // lookup_ref_def is dropped before &mut self calls. let dest: Box<[u8]> = Box::from(&ref_def.dest[..]); let title: Box<[u8]> = Box::from(&ref_def.title[..]); + let has_escape = ref_def.has_escape; if !self.charge_ref_def_output(dest.len(), title.len()) { return Ok(None); } @@ -527,6 +554,7 @@ impl Parser<'_> { &title, is_image, Reference::Shortcut, + has_escape, base + start, )?; return Ok(Some(LabelParse { @@ -769,13 +797,8 @@ impl Parser<'_> { // Render the wikilink self.at(base + start, base + start + 2); - self.renderer.enter_span( - Span::Wikilink, - SpanAttrs { - href: target, - ..Default::default() - }, - )?; + self.renderer + .enter_span(Span::Wikilink, SpanAttrs::wikilink(target))?; let label_start = match m.pipe_pos { Some(pp) => pp + 1, None => m.inner_start, @@ -895,15 +918,8 @@ impl Parser<'_> { beg: usize, ) -> crate::types::JsResult<()> { self.at(beg, beg + 1); - self.renderer.enter_span( - Span::A, - SpanAttrs { - href: url, - autolink: true, - autolink_email: is_email, - ..Default::default() - }, - )?; + self.renderer + .enter_span(Span::A, SpanAttrs::autolink(url, is_email))?; self.at(beg + 1, beg + 1 + url.len()); self.emit_text(TextType::Normal, url)?; self.at(beg + 1 + url.len(), beg + 2 + url.len()); @@ -943,14 +959,8 @@ impl Parser<'_> { return Ok(()); } self.at(beg, beg + 2); - self.renderer.enter_span( - Span::A, - SpanAttrs { - href: label, - reference: Reference::Footnote, - ..Default::default() - }, - )?; + self.renderer + .enter_span(Span::A, SpanAttrs::footnote_reference(label))?; self.at(beg + 2 + label.len(), beg + 3 + label.len()); self.renderer.leave_span(Span::A) } diff --git a/src/md/ref_defs.rs b/src/md/ref_defs.rs index 85322910d6b7..f58c666583e8 100644 --- a/src/md/ref_defs.rs +++ b/src/md/ref_defs.rs @@ -16,6 +16,8 @@ pub(crate) const MAX_LINK_LABEL_LEN: usize = 999; pub(crate) struct RefDef { pub(crate) dest: Box<[u8]>, // raw destination (slice of source) pub(crate) title: Box<[u8]>, // raw title (slice of source) + /// `dest` or `title` holds a `\` or a `&`. + pub(crate) has_escape: bool, } pub(crate) struct ParsedRefDef<'a> { @@ -23,11 +25,15 @@ pub(crate) struct ParsedRefDef<'a> { pub(crate) label: &'a [u8], pub(crate) dest: &'a [u8], pub(crate) title: &'a [u8], + /// `dest` or `title` holds a `\` or a `&`. + pub(crate) has_escape: bool, } pub(crate) struct ParsedTitle<'a> { pub(crate) title: &'a [u8], pub(crate) end_pos: usize, + /// `title` holds a `\` or a `&`. + pub(crate) has_escape: bool, } impl Parser<'_> { @@ -149,6 +155,7 @@ impl Parser<'_> { let dest_result = self.parse_ref_def_dest(text, p)?; p = dest_result.end_pos; let dest = dest_result.dest; + let dest_has_escape = dest_result.has_escape; // Save position before trying title (may need to backtrack) let pos_after_dest = p; @@ -192,6 +199,7 @@ impl Parser<'_> { label, dest, title, + has_escape: dest_has_escape || title_result.has_escape, }); } // Title present but not followed by end of line — if title was on same line as dest, invalid @@ -225,6 +233,7 @@ impl Parser<'_> { label, dest, title, + has_escape: dest_has_escape, }) } @@ -273,27 +282,37 @@ impl Parser<'_> { }; p += 1; let title_start = p; + let mut has_escape = false; while p < text.len() && text[p] != close_char { - if text[p] == b'\\' && p + 1 < text.len() { - p += 2; - } else { - // For () titles, nested ( is not allowed - if open_char == b'(' - && text[p] == b'(' - && !compat::parenthesis_can_be_in_a_title(&self.flags) - { - return None; + if text[p] == b'\\' { + has_escape = true; + if p + 1 < text.len() { + p += 2; + continue; } - p += 1; + } else if text[p] == b'&' { + has_escape = true; } + // For () titles, nested ( is not allowed + if open_char == b'(' + && text[p] == b'(' + && !compat::parenthesis_can_be_in_a_title(&self.flags) + { + return None; + } + p += 1; } if p >= text.len() { return None; // no closing quote/paren } let title = &text[title_start..p]; p += 1; // skip close - Some(ParsedTitle { title, end_pos: p }) + Some(ParsedTitle { + title, + end_pos: p, + has_escape, + }) } pub(crate) fn build_ref_def_hashtable(&mut self) -> Result<(), AllocError> { @@ -396,6 +415,7 @@ impl Parser<'_> { self.ref_defs.push(RefDef { dest: dest_dupe, title: title_dupe, + has_escape: result.has_escape, }); } diff --git a/src/md/types.rs b/src/md/types.rs index 3ffc88b33b92..d7e670852715 100644 --- a/src/md/types.rs +++ b/src/md/types.rs @@ -1,3 +1,5 @@ +use std::borrow::Cow; + // The md crate sits below `bun_jsc` in the layering, so `bun_jsc::JsResult` // is unreachable here; this local alias plays the same role. pub type JsResult = Result; @@ -173,8 +175,10 @@ impl<'a> Renderer<'a> { /// renderers that retain them past that call must copy. #[derive(Copy, Clone)] pub struct SpanDetail<'a> { - pub href: &'a [u8], - pub title: &'a [u8], + href: &'a [u8], + title: &'a [u8], + /// Whether `href()` and `title()` look for a backslash escape or a character reference. + resolves: bool, /// Standard autolink (angle-bracket): use writeUrlEscaped (no entity/escape processing) pub autolink: bool, /// Standard autolink is an email: prepend "mailto:" to href @@ -206,6 +210,7 @@ impl<'a> Default for SpanDetail<'a> { Self { href: b"", title: b"", + resolves: false, autolink: false, autolink_email: false, permissive_autolink: false, @@ -215,6 +220,110 @@ impl<'a> Default for SpanDetail<'a> { } } +// One is made for every span: the flags take one word behind the two slices. +const _: () = assert!(size_of::>() == 2 * size_of::<&[u8]>() + 8); + +impl<'a> SpanDetail<'a> { + /// A link or an image with a written destination. Without `resolves`, neither holds a `\` or a `&`. + pub(crate) fn link( + href: &'a [u8], + title: &'a [u8], + reference: Reference, + resolves: bool, + ) -> Self { + debug_assert!( + resolves + || (bun_core::strings::index_of_any(href, b"\\&").is_none() + && bun_core::strings::index_of_any(title, b"\\&").is_none()) + ); + Self { + href, + title, + reference, + resolves, + ..Self::default() + } + } + + /// `` or ``: `href` is the text between the brackets. + pub(crate) fn autolink(href: &'a [u8], is_email: bool) -> Self { + Self { + href, + autolink: true, + autolink_email: is_email, + ..Self::default() + } + } + + /// A URL, an e-mail address or a `www.` name in the text: `href` is that text. + pub(crate) fn permissive_autolink(href: &'a [u8], is_email: bool, is_www: bool) -> Self { + Self { + href, + permissive_autolink: true, + autolink_email: is_email, + autolink_www: is_www, + ..Self::default() + } + } + + /// `[[target]]` or `[[target|label]]`: `href` is the target. + pub(crate) fn wikilink(target: &'a [u8]) -> Self { + Self { + href: target, + ..Self::default() + } + } + + /// `[^label]`: `href` is the label. + pub(crate) fn footnote_reference(label: &'a [u8]) -> Self { + Self { + href: label, + reference: Reference::Footnote, + ..Self::default() + } + } + + /// Where it goes, as html() writes it before it encodes: escapes resolved, the scheme of an autolink added. + #[inline] + pub fn href(&self) -> Cow<'a, [u8]> { + let scheme: &[u8] = if self.autolink_email { + b"mailto:" + } else if self.autolink_www { + b"http://" + } else { + return self.resolved(self.href); + }; + Cow::Owned(bun_core::strings::concat(&[scheme, self.href]).into_vec()) + } + + /// The title of a link or an image, with its escapes resolved. Empty if it has none. + #[inline] + pub fn title(&self) -> Cow<'a, [u8]> { + self.resolved(self.title) + } + + /// The bytes of the document, without a scheme: for who resolves them while it writes, or keeps them. + #[inline] + pub fn href_as_written(&self) -> &'a [u8] { + self.href + } + + /// The bytes of the document, like `href_as_written()`. + #[inline] + pub fn title_as_written(&self) -> &'a [u8] { + self.title + } + + #[inline] + fn resolved(&self, written: &'a [u8]) -> Cow<'a, [u8]> { + if self.resolves && !written.is_empty() { + crate::helpers::resolve_escapes(written) + } else { + Cow::Borrowed(written) + } + } +} + // --- Internal types used by the parser --- /// Line types during block analysis. diff --git a/src/runtime/api/MarkdownObject.rs b/src/runtime/api/MarkdownObject.rs index 4380f18d6071..0afa23a98ed0 100644 --- a/src/runtime/api/MarkdownObject.rs +++ b/src/runtime/api/MarkdownObject.rs @@ -847,8 +847,8 @@ impl<'a> ParseRenderer<'a> { self.marked_args.append(array); self.stack.push(ParseStackEntry { children: array, - href: Box::from(detail.href), - title: Box::from(detail.title), + href: Box::from(detail.href()), + title: Box::from(detail.title()), ..Default::default() }); Ok(()) @@ -1361,8 +1361,8 @@ impl<'a> JsCallbackRenderer<'a> { let collects = !self.get_span_callback(span_type).is_empty(); let entry = if collects { CallbackStackEntry { - href: Box::from(detail.href), - title: Box::from(detail.title), + href: Box::from(detail.href()), + title: Box::from(detail.title()), ..Default::default() } } else { diff --git a/test/cli/run/markdown-entrypoint.test.ts b/test/cli/run/markdown-entrypoint.test.ts index b27b69446d60..629d2d70e794 100644 --- a/test/cli/run/markdown-entrypoint.test.ts +++ b/test/cli/run/markdown-entrypoint.test.ts @@ -347,6 +347,97 @@ describe("bun ", () => { expect(openers).toBeGreaterThanOrEqual(2); }); + // The path of an image is what its destination means: `\_` is `_`. A file + // can also have the name that is written: `..\..\x.png` has worked so far. + test("a local image is found by the path that its destination means, or by the path as written", async () => { + // Windows has no file names with a tab. + const withTab = isPosix ? { file: { "x/y/t\tb.png": "6" }, image: ["![6]()"], shown: ["6"] } : undefined; + using dir = tempDir("md-local-img-", { + "img/logo.png": "1", + ".github/logo.png": "2", + "x/y/a_b.png": "3", + "x/y/img_1/a.png": "4", + "x/y/c_d.png": "5", + "x/y/c/_d.png": "not this one", + ...withTab?.file, + "x/y/doc.md": [ + String.raw`![1](..\..\img\logo.png)`, + String.raw`![2](..\..\.github\logo.png)`, + String.raw`![3](a\_b.png)`, + String.raw`![4](.\img_1\a.png)`, + String.raw`![5](c\_d.png)`, + ...(withTab?.image ?? []), + "", + ].join("\n\n"), + }); + const cwd = join(String(dir), "x", "y"); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `process.stdout.write(Bun.markdown.ansi(require("fs").readFileSync("doc.md"), { kittyGraphics: true }))`, + ], + env: bunEnv, + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, _stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // Kitty is told to show the file at each of these paths. + const shown = Array.from(stdout.matchAll(/\x1b_Ga=T,t=f,f=100,q=2;([A-Za-z0-9+/=]+)\x1b\\/g), ([, path]) => + readFileSync(Buffer.from(path, "base64").toString(), "utf8"), + ); + expect(shown).toEqual(["1", "2", "3", "4", "5", ...(withTab?.shown ?? [])]); + expect(exitCode).toBe(0); + }); + + // The URL of an image is what its destination means: `&` is `&`. The + // image is downloaded from that URL, and the renderer finds the download + // under it. + // Kitty graphics need a terminal, and `Bun.spawn` has one on POSIX only. + test.skipIf(!isPosix)("a remote image is downloaded from the URL that its destination means", async () => { + const requested: string[] = []; + await using server = Bun.serve({ + port: 0, + fetch(req) { + requested.push(new URL(req.url).search); + return new Response(Buffer.alloc(64, "z"), { headers: { "content-type": "image/png" } }); + }, + }); + using dir = tempDir("md-remote-img-url-", { + "doc.md": `![img](http://127.0.0.1:${server.port}/img.png?a=1&b=2)\n`, + "tmp/keep": "", + }); + const tmp = join(String(dir), "tmp"); + const decoder = new TextDecoder(); + const closed = Promise.withResolvers(); + let output = ""; + await using proc = Bun.spawn({ + cmd: [bunExe(), "./doc.md"], + env: { ...bunEnv, FORCE_COLOR: "1", TERM: "xterm-kitty", KITTY_WINDOW_ID: "1", BUN_TMPDIR: tmp, TMPDIR: tmp }, + cwd: String(dir), + terminal: { + cols: 80, + rows: 24, + data(_terminal, chunk) { + output += decoder.decode(chunk, { stream: true }); + }, + // The terminal closes when the child is gone: all of its output has been read. + exit: () => closed.resolve(), + }, + }); + const [exitCode] = await Promise.all([proc.exited, closed.promise]); + proc.terminal?.close(); + + expect(requested).toEqual(["?a=1&b=2"]); + const staged = readdirSync(tmp).filter(name => name.startsWith("bun-md-")); + expect(staged).toHaveLength(1); + expect(output).toContain( + "\x1b_Ga=T,t=f,f=100,q=2;" + Buffer.from(join(tmp, staged[0])).toString("base64") + "\x1b\\", + ); + expect(exitCode).toBe(0); + }); + test.skipIf(!isPosix || !(isDebug || isASAN))( "prefetched remote images are staged into fresh temp files and never written through a pre-existing entry at the staged path", async () => { diff --git a/test/js/bun/md/md-edge-cases.test.ts b/test/js/bun/md/md-edge-cases.test.ts index e0c247cf5b11..7a3884694952 100644 --- a/test/js/bun/md/md-edge-cases.test.ts +++ b/test/js/bun/md/md-edge-cases.test.ts @@ -6,6 +6,16 @@ import { renderToString } from "react-dom/server"; const Markdown = Bun.markdown; +/** The Markdown of every example in a spec file of this directory. */ +function specExamples(file: string): string[] { + const content = readFileSync(join(import.meta.dir, file), "utf8").replace(/\r\n?/g, "\n"); + const examples: string[] = []; + for (const match of content.matchAll(/^`{32} example\n([\s\S]*?)^\.$/gm)) { + examples.push(match[1].replaceAll("\u2192", "\t") + "\n"); + } + return examples; +} + // Runs `script` in a child that is killed after 30s, so a super-linear // regression fails fast instead of hanging the test runner. async function expectRendersQuickly(script: string) { @@ -1225,15 +1235,6 @@ describe("render() agrees with a fold over the react() tree", () => { return meta === undefined ? cb(children) : cb(children, meta); } - function specExamples(file: string): string[] { - const content = readFileSync(join(import.meta.dir, file), "utf8").replace(/\r\n?/g, "\n"); - const examples: string[] = []; - for (const match of content.matchAll(/^`{32} example\n([\s\S]*?)^\.$/gm)) { - examples.push(match[1].replaceAll("\u2192", "\t") + "\n"); - } - return examples; - } - const callbackSets = [ names, [], @@ -1429,6 +1430,184 @@ describe("ansi renderer link and metadata control bytes", () => { }); }); +// ============================================================================ +// A backslash escape and a character reference in a link destination or a link +// title stand for a character (CommonMark 6.3), and an e-mail or `www.` +// autolink has a scheme. html() writes that value into the attribute. react() +// has to hand on the same value. render() does too: its output is compared +// with a fold over the react() tree above. +// ============================================================================ + +describe("react() hands on the link destinations and link titles that html() writes", () => { + type Value = { a?: string; img?: string; title?: string }; + + /** The href and title of every `a`, and the src and title of every `img`. */ + function fromReact(markdown: string | Buffer, options?: object): Value[] { + const found: Value[] = []; + const walk = (node: any) => { + if (Array.isArray(node)) return node.forEach(walk); + if (node == null || typeof node !== "object") return; + const { type, props } = node; + if (type === "a" && props.href !== undefined) found.push({ a: props.href, title: props.title }); + if (type === "img") found.push({ img: props.src, title: props.title }); + walk(props.children); + }; + walk(Markdown.react(markdown as string, undefined, { ...options, reactVersion: 18 })); + return found; + } + + // A model of how html() encodes a value for an attribute, which react() does + // not do: a title has `& < > "` escaped, a destination is percent-encoded too. + const escapeHtml = (value?: string) => + value?.replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!); + const urlSafe = /[-A-Za-z0-9._~:/?#@!$()*+,;=%]/; + const escapeUrl = (value?: string) => + value === undefined + ? undefined + : Array.from(Buffer.from(value), byte => { + const c = String.fromCharCode(byte); + if (c === "&") return "&"; + if (c === "'") return "'"; + return urlSafe.test(c) ? c : "%" + byte.toString(16).toUpperCase().padStart(2, "0"); + }).join(""); + const asWritten = ({ a, img, title }: Value): Value => ({ + a: escapeUrl(a), + img: escapeUrl(img), + title: escapeHtml(title), + }); + + /** The same values, as they are in the attributes of what html() writes. */ + function fromHtml(markdown: string | Buffer, options?: object): Value[] { + const tags = + /|[^/g; + return Array.from(Markdown.html(markdown as string, options).matchAll(tags), ({ groups }) => { + const { a, title, img, imgTitle } = groups!; + return { a, img, title: title ?? imgTitle }; + }); + } + + // One test per 100 examples keeps each test near a second on a debug build. + const chunkSize = 100; + for (const [file, options] of [ + ["spec.txt", {}], + ["spec-gfm.txt", {}], + ["spec-permissive-autolinks.txt", { autolinks: true }], + ["regressions.txt", {}], + ["coverage.txt", {}], + ] as const) { + const examples = specExamples(file); + for (let start = 0; start < examples.length; start += chunkSize) { + const chunk = examples.slice(start, start + chunkSize); + test(`${file} examples ${start + 1}-${start + chunk.length}`, () => { + const mismatches: unknown[] = []; + for (const markdown of chunk) { + // html() copies raw HTML, which can have an `` or an `` of its own. + if (/<(a|img)\s/i.test(markdown)) continue; + const react = fromReact(markdown, options).map(asWritten); + const html = fromHtml(markdown, options); + if (!Bun.deepEquals(react, html) && mismatches.length < 5) mismatches.push({ markdown, react, html }); + } + expect(mismatches).toEqual([]); + }); + } + } + + // What the spec files do not have. The values are given, and are compared + // with html() too, so this also fails if the pattern above finds nothing. + test.each([ + // An escape that is next to a link and not in it, or in another link. + [String.raw`a \* b [x](/y "t") & ![i](/z)`, [{ a: "/y", title: "t" }, { img: "/z" }]], + [ + String.raw`[a](/a "t") and [b](/b\_c "R&D")`, + [ + { a: "/a", title: "t" }, + { a: "/b_c", title: "R&D" }, + ], + ], + [String.raw`[a\_b & c](/d) ![e\*f](/g)`, [{ a: "/d" }, { img: "/g" }]], + // In the title and not in the destination, and the other way around. + [ + String.raw`[a](/a "t\*t") [b]( 't')`, + [ + { a: "/a", title: "t*t" }, + { a: "/b&c", title: "t" }, + ], + ], + // In another block, and in a cell of a table. + ["[a](/a\\_1)\n\n[b](/b)\n", [{ a: "/a_1" }, { a: "/b" }]], + ["[a](/a)\n\n[b](/b\\_1 'x&y')\n", [{ a: "/a" }, { a: "/b_1", title: "x&y" }]], + [ + "| h |\n|---|\n| [a](/a\\_1) [b](/b) |\n| [c](/c\\|d) |\n| [e](/e&f) |\n", + [{ a: "/a_1" }, { a: "/b" }, { a: "/c|d" }, { a: "/e&f" }], + ], + [ + "# [a](/a\\_1)\n\n- [b](/b&c)\n\n> ![d](/d\\*e 'f<g')\n", + [{ a: "/a_1" }, { a: "/b&c" }, { img: "/d*e", title: "f (t©)\n[s]: /s\\_1 't'\n[t]: /t 'x&y'\n", + [ + { a: "/r_1", title: "t©" }, + { img: "/r_1", title: "t©" }, + { a: "/c" }, + { a: "/s_1", title: "t" }, + { a: "/t", title: "x&y" }, + ], + ], + // What is left is an escape or a character reference again: it is resolved once. + [String.raw`[a](/x\&y "\\\*&lt;")`, [{ a: "/x&y", title: String.raw`\*<` }]], + ] as const)("%j", (markdown, expected) => { + for (const input of [markdown, Buffer.from(markdown)]) { + const react = fromReact(input); + expect(react).toEqual(expected as Value[]); + expect(react.map(asWritten)).toEqual(fromHtml(input)); + } + }); +}); + +// ============================================================================ +// ANSI renderer: a link destination, an image source and a title mean what +// html() writes for them. Backslash escapes and character references are +// resolved. A character reference can stand for a control byte, so those +// bytes are stripped after the resolving, not before it. +// ============================================================================ + +describe("ansi renderer escapes and character references in link destinations and titles", () => { + test("are resolved", () => { + const md = String.raw`[wiki]() and ![](a\_b.png "R&D")` + "\n"; + expect(Markdown.ansi(md, { colors: false })).toBe("wiki (https://example.com/Foo_(bar)?a=1&b=2) and [img] R&D\n"); + const linked = Markdown.ansi(md, { hyperlinks: true }); + expect(linked).toContain("\x1b]8;;https://example.com/Foo_(bar)?a=1&b=2\x1b\\"); + expect(linked).toContain("\x1b]8;;a_b.png\x1b\\"); + + const byReference = "[a][r] ![b][r]\n\n" + String.raw`[r]: "R&D"` + "\n"; + expect(Markdown.ansi(byReference, { colors: false })).toBe("a (https://example.com/a_b) [img] b\n"); + }); + + test("a character reference cannot put a control byte into the output", () => { + // `` is ESC, `` is BEL, `›` is CSI. A line feed (` `) + // or a tab (` `) in a destination would break the OSC 8 sequence. Only + // these bytes go: what follows them is part of the address, as in html(). + const linkMd = String.raw`[click]()` + "\n"; + expect(Markdown.ansi(linkMd, { colors: false })).toBe("click (x]52;c;Y3VybCBldmlsLnNoIHwgc2g=yzw31m)\n"); + expect(Markdown.ansi(linkMd, { hyperlinks: true })).toContain( + "\x1b]8;;x]52;c;Y3VybCBldmlsLnNoIHwgc2g=yzw31m\x1b\\", + ); + expect(Markdown.ansi("[a](https://example.com/x]y/long/path)\n", { colors: false })).toBe( + "a (https://example.com/x]y/long/path)\n", + ); + + // The title is text: an escape sequence goes as a whole. + const imgMd = String.raw`![]( "ti]0;ownedtle")` + "\n"; + expect(Markdown.ansi(imgMd, { colors: false })).toBe("[img] title\n"); + expect(Markdown.ansi(imgMd, { hyperlinks: true })).toContain("\x1b]8;;i]0;owned.png\x1b\\"); + + const byReference = "[a][r]\n\n" + String.raw`[r]: "t"` + "\n"; + expect(Markdown.ansi(byReference, { colors: false })).toBe("a (https://example.com/ab[31m)\n"); + }); +}); + describe("ansi renderer line wrapping", () => { const sources = [ "The declaration and the assignment can be one `const`. Then (see `let`), use **bold text**: it is *fine*; [a link](https://example.com/x), and `more code`.\n", diff --git a/test/js/bun/md/md-react.test.ts b/test/js/bun/md/md-react.test.ts index e3ae89752475..109c5442a9a2 100644 --- a/test/js/bun/md/md-react.test.ts +++ b/test/js/bun/md/md-react.test.ts @@ -79,6 +79,27 @@ describe("Bun.markdown.react", () => { expect(link.props.children).toEqual(["click"]); }); + // https://github.com/oven-sh/bun/issues/31936 + test.each([ + // [markdown, parser options, href, link text] + ["", undefined, "mailto:email@example.com", "email@example.com"], + ["email@example.com", { autolinks: true }, "mailto:email@example.com", "email@example.com"], + ["www.example.com", { autolinks: true }, "http://www.example.com", "www.example.com"], + ["", undefined, "https://example.com", "https://example.com"], + ["https://example.com", { autolinks: true }, "https://example.com", "https://example.com"], + ["", undefined, "mailto:email@example.com", "mailto:email@example.com"], + // Not an autolink: the destination stays as written. + ["[text](email@example.com)", { autolinks: true }, "email@example.com", "text"], + ["[text](www.example.com)", { autolinks: true }, "www.example.com", "text"], + ])("href of %j has the same scheme as in html()", (md, opts, href, text) => { + const link = children(md + "\n", undefined, opts)[0].props.children[0]; + expect({ type: link.type, href: link.props.href, children: link.props.children }).toEqual({ + type: "a", + href, + children: [text], + }); + }); + test("image has src and alt in props", () => { const img = children("![alt](img.png)\n")[0].props.children[0]; expect(img.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL); @@ -127,6 +148,92 @@ describe("Bun.markdown.react", () => { }); }); + // A backslash escape and a character reference in a link destination or a + // link title stand for a character (CommonMark 6.3). The props hold that + // character, like the attributes that html() writes. + describe("href, src and title have escapes and character references resolved", () => { + /** The props of every `a` and `img` in the document, without their children. */ + function props(md: string | Buffer, opts?: any) { + const found: [string, object][] = []; + const walk = (node: any) => { + if (Array.isArray(node)) return node.forEach(walk); + if (node == null || typeof node !== "object") return; + const { children, ...rest } = node.props; + if (node.type === "a" || node.type === "img") found.push([node.type, rest]); + walk(children); + }; + walk(Markdown.react(md as string, undefined, opts)); + return found; + } + + test.each([ + [ + "inline link", + String.raw`[wiki]( "R&D \"notes\"")`, + [["a", { href: "https://example.com/wiki/Foo_(bar)", title: 'R&D "notes"' }]], + ], + [ + "character references", + String.raw`[a](/?a=1&b=2&c=3 "ööö")`, + [["a", { href: "/?a=1&b=2&c=3", title: "ööö" }]], + ], + ["image", String.raw`![logo](a\_b.png "t\*t")`, [["img", { src: "a_b.png", title: "t*t", alt: "logo" }]]], + [ + "image in a link", + String.raw`[![alt](i\_1.png)](/u&v)`, + [ + ["a", { href: "/u&v" }], + ["img", { src: "i_1.png", alt: "alt" }], + ], + ], + [ + "full, collapsed and shortcut reference", + "[t][r] [r][] [r]\n\n" + String.raw`[r]: 'x © y'`, + [ + ["a", { href: "/a_b", title: "x © y" }], + ["a", { href: "/a_b", title: "x © y" }], + ["a", { href: "/a_b", title: "x © y" }], + ], + ], + [ + "image by reference", + "![alt][r]\n\n" + String.raw`[r]: a\_b.png "R&D"`, + [["img", { src: "a_b.png", title: "R&D", alt: "alt" }]], + ], + [ + "what is not an escape or a character reference stays", + String.raw`[a](a\b&nosuch;&c "\a&#;") [b](/x\&y) [c](/x\\y)`, + [ + ["a", { href: String.raw`a\b&nosuch;&c`, title: String.raw`\a&#;` }], + ["a", { href: "/x&y" }], + ["a", { href: String.raw`/x\y` }], + ], + ], + // CommonMark 6.5: "Backslash-escapes do not work inside autolinks". + [ + "a backslash in an autolink", + String.raw``, + [["a", { href: String.raw`https://example.com/a\_b` }]], + ], + ] as const)("%s", (_, md, expected) => { + expect(props(md)).toEqual(expected as any); + expect(props(Buffer.from(md))).toEqual(expected as any); + }); + + // Same as html() today: it does not resolve a character reference in an + // autolink, and nothing in the target of a wiki link. + test("an autolink and the target of a wiki link are what html() writes", () => { + const unescaped = (attribute: string) => + attribute.replaceAll(""", '"').replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&"); + const autolink = String.raw``; + const href = /href="([^"]*)"/.exec(Markdown.html(autolink))![1]; + expect(props(autolink)).toEqual([["a", { href: unescaped(href) }]]); + const wikiLink = String.raw`[[a\_b&c|label]]`; + const target = /data-target="([^"]*)"/.exec(Markdown.html(wikiLink, { wikiLinks: true }))![1]; + expect(props(wikiLink, { wikiLinks: true })).toEqual([["a", { target: unescaped(target) }]]); + }); + }); + test("code block with language", () => { const pre = children("```ts\nconst x = 1;\n```\n")[0]; expect(pre.$$typeof).toBe(REACT_TRANSITIONAL_SYMBOL); diff --git a/test/js/bun/md/md-render-callback.test.ts b/test/js/bun/md/md-render-callback.test.ts index 4f057a86beb3..60d53f7e8a1d 100644 --- a/test/js/bun/md/md-render-callback.test.ts +++ b/test/js/bun/md/md-render-callback.test.ts @@ -111,6 +111,89 @@ describe("Bun.markdown.render", () => { expect(result).toBe('alt text'); }); + // A backslash escape and a character reference in a link destination or a + // link title stand for a character (CommonMark 6.3). The meta holds that + // character, like the attributes that html() writes. + describe("href, src and title have escapes and character references resolved", () => { + /** The meta of every link and image in the document. */ + function metas(md: string | Buffer, opts?: any) { + const seen: [string, unknown][] = []; + const take = (name: string) => (children: string, meta: unknown) => (seen.push([name, meta]), children); + Markdown.render(md as string, { link: take("link"), image: take("image") }, opts); + return seen; + } + + test.each([ + [ + "inline link", + String.raw`[wiki]( "R&D \"notes\"")`, + [["link", { href: "https://example.com/wiki/Foo_(bar)", title: 'R&D "notes"' }]], + ], + [ + "character references", + String.raw`[a](/?a=1&b=2&c=3 "ööö")`, + [["link", { href: "/?a=1&b=2&c=3", title: "ööö" }]], + ], + ["image", String.raw`![logo](a\_b.png "t\*t")`, [["image", { src: "a_b.png", title: "t*t" }]]], + [ + "image in a link", + String.raw`[![alt](i\_1.png)](/u&v)`, + [ + ["image", { src: "i_1.png" }], + ["link", { href: "/u&v" }], + ], + ], + [ + "full, collapsed and shortcut reference", + "[t][r] [r][] [r]\n\n" + String.raw`[r]: 'x © y'`, + [ + ["link", { href: "/a_b", title: "x © y" }], + ["link", { href: "/a_b", title: "x © y" }], + ["link", { href: "/a_b", title: "x © y" }], + ], + ], + [ + "image by reference", + "![alt][r]\n\n" + String.raw`[r]: a\_b.png "R&D"`, + [["image", { src: "a_b.png", title: "R&D" }]], + ], + [ + "what is not an escape or a character reference stays", + String.raw`[a](a\b&nosuch;&c "\a&#;") [b](/x\&y) [c](/x\\y)`, + [ + ["link", { href: String.raw`a\b&nosuch;&c`, title: String.raw`\a&#;` }], + ["link", { href: "/x&y" }], + ["link", { href: String.raw`/x\y` }], + ], + ], + // CommonMark 6.5: "Backslash-escapes do not work inside autolinks". + [ + "a backslash in an autolink", + String.raw``, + [["link", { href: String.raw`https://example.com/a\_b` }]], + ], + ] as const)("%s", (_, md, expected) => { + expect(metas(md)).toEqual(expected as any); + expect(metas(Buffer.from(md))).toEqual(expected as any); + }); + + // The parser reads a Buffer in place. It cuts the second destination after + // the callback of the first link has written an escape into it. + test("an escape that a callback writes into a later destination", () => { + const source = "x [a](/b-c) then [d](/eYZf) y\n"; + const buffer = Buffer.from(source); + const hrefs: string[] = []; + Markdown.render(buffer, { + link: (children: string, { href }: any) => { + hrefs.push(href); + buffer.write(String.raw`\_`, source.indexOf("YZ")); + return children; + }, + }); + expect(hrefs).toEqual(["/b-c", "/e_f"]); + }); + }); + test("code block callback with language metadata", () => { const result = Markdown.render("```js\nconsole.log('hi');\n```\n", { code: (children: string, meta: any) => `
${children}
`, @@ -375,6 +458,33 @@ describe("Bun.markdown.render", () => { expect(result).toContain("[www.example.com]"); }); + // https://github.com/oven-sh/bun/issues/31936 + test("link meta href has the same scheme as in html()", () => { + const cases = [ + ["", "mailto:email@example.com"], + ["email@example.com", "mailto:email@example.com"], + ["www.example.com", "http://www.example.com"], + ["", "https://example.com"], + ["https://example.com", "https://example.com"], + ["", "mailto:email@example.com"], + // Not an autolink: the destination stays as written. + ["[text](email@example.com)", "email@example.com"], + ["[text](www.example.com)", "www.example.com"], + ]; + const hrefs: string[] = []; + Markdown.render( + cases.map(([md]) => md).join("\n\n") + "\n", + { + link: (children: string, { href }: any) => { + hrefs.push(href); + return children; + }, + }, + { autolinks: true }, + ); + expect(hrefs).toEqual(cases.map(([, href]) => href)); + }); + test("headings option provides id in heading meta", () => { const result = Markdown.render( "## Hello World\n",