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.
");
+ }
+}