From ce7efb11cd70b84a53500aad71afbaa92721b78b Mon Sep 17 00:00:00 2001 From: Nanook Claw Date: Tue, 19 May 2026 19:15:54 +0000 Subject: [PATCH] fix(html): backtrack jsdoc summary ending with ':' to last '.' --- src/html/comrak.rs | 173 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/src/html/comrak.rs b/src/html/comrak.rs index 685608637..a855df197 100644 --- a/src/html/comrak.rs +++ b/src/html/comrak.rs @@ -63,6 +63,118 @@ fn walk_node_title<'a>(node: &'a AstNode<'a>) { } } +/// Append the inline plain-text contribution of `node` to `out`. +/// Counts `NodeValue::Text` and `NodeValue::Code` literals, and treats +/// `NodeValue::SoftBreak` as a single space. Other inline containers +/// (Emph, Strong, Link, etc.) descend into their children. +fn collect_inline_plain_text<'a>(node: &'a AstNode<'a>, out: &mut String) { + match &node.data.borrow().value { + NodeValue::Text(t) => out.push_str(t), + NodeValue::Code(c) => out.push_str(&c.literal), + NodeValue::SoftBreak => out.push(' '), + _ => { + for child in node.children() { + collect_inline_plain_text(child, out); + } + } + } +} + +/// Shorten the inline tree rooted at `node` so that, in document order, +/// the cumulative plain-text contribution is exactly `target` bytes. +/// Returns true once `*offset >= target`, signalling to callers that the +/// rest of the tree should be detached. +fn shorten_inline_to<'a>( + node: &'a AstNode<'a>, + offset: &mut usize, + target: usize, +) -> bool { + if *offset >= target { + return true; + } + + enum Kind { + Text, + Code, + SoftBreak, + Container, + } + + let kind = match &node.data.borrow().value { + NodeValue::Text(_) => Kind::Text, + NodeValue::Code(_) => Kind::Code, + NodeValue::SoftBreak => Kind::SoftBreak, + _ => Kind::Container, + }; + + match kind { + Kind::Text => { + let mut data = node.data.borrow_mut(); + if let NodeValue::Text(t) = &mut data.value { + let len = t.len(); + if *offset + len <= target { + *offset += len; + } else { + let take = target - *offset; + t.replace_range(take.., ""); + *offset = target; + } + } + } + Kind::Code => { + let mut data = node.data.borrow_mut(); + if let NodeValue::Code(c) = &mut data.value { + let len = c.literal.len(); + if *offset + len <= target { + *offset += len; + } else { + let take = target - *offset; + c.literal.replace_range(take.., ""); + *offset = target; + } + } + } + Kind::SoftBreak => { + *offset += 1; + } + Kind::Container => { + let children: Vec<_> = node.children().collect(); + let mut done = false; + for child in children { + if done { + child.detach(); + } else { + done = shorten_inline_to(child, offset, target); + } + } + } + } + + *offset >= target +} + +/// If the first paragraph's trimmed plain text ends with `':'` and +/// contains a `'.'`, shorten the paragraph so the rendered output ends +/// right after the last `'.'`. Does nothing otherwise. See +/// . +fn shorten_title_summary_trailing_colon<'a>(paragraph: &'a AstNode<'a>) { + let mut plain = String::new(); + collect_inline_plain_text(paragraph, &mut plain); + + let trimmed = plain.trim_end_matches(|c: char| c.is_ascii_whitespace()); + if !trimmed.ends_with(':') { + return; + } + + let Some(last_dot) = trimmed.rfind('.') else { + return; + }; + + let target = last_dot + 1; + let mut offset = 0; + shorten_inline_to(paragraph, &mut offset, target); +} + pub fn render_node<'a>( node: &'a AstNode<'a>, options: &comrak::Options, @@ -152,6 +264,7 @@ pub fn create_renderer( walk_node_title(root); if let Some(child) = root.first_child() { + shorten_title_summary_trailing_colon(child); render_node(child, &options, &plugins) } else { return None; @@ -254,3 +367,63 @@ impl SyntaxHighlighterAdapter for ComrakHighlightWrapperAdapter { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn render_title_only(md: &str) -> String { + let mut options = default_options(); + options.render.escape = true; + + let arena = Arena::new(); + let root = comrak::parse_document(&arena, md, &options); + walk_node_title(root); + + let Some(child) = root.first_child() else { + return String::new(); + }; + shorten_title_summary_trailing_colon(child); + render_node(child, &options, &comrak::Plugins::default()) + } + + #[test] + fn title_summary_trailing_colon_with_period_shortens_to_last_period() { + let html = render_title_only("This is a summary. It says:"); + assert_eq!(html.trim(), "

This is a summary.

"); + } + + #[test] + fn title_summary_trailing_colon_without_period_left_unchanged() { + let html = render_title_only("This is a summary:"); + assert_eq!(html.trim(), "

This is a summary:

"); + } + + #[test] + fn title_summary_no_trailing_colon_left_unchanged() { + let html = render_title_only("This is a summary."); + assert_eq!(html.trim(), "

This is a summary.

"); + } + + #[test] + fn title_summary_trailing_colon_after_code_span_shortens() { + // Period before the code+colon tail — backtracking should drop the + // code span and trailing colon entirely. + let html = render_title_only("First. Then `something`:"); + assert_eq!(html.trim(), "

First.

"); + } + + #[test] + fn title_summary_trailing_colon_after_emph_shortens() { + let html = render_title_only("First. Then *emph*:"); + assert_eq!(html.trim(), "

First.

"); + } + + #[test] + fn title_summary_period_inside_code_span_keeps_code_up_to_period() { + // The last '.' lives inside a code span; shortening lands exactly at + // the end of that span and drops the following text+colon. + let html = render_title_only("Some `code.` text:"); + assert_eq!(html.trim(), "

Some code.

"); + } +}