Skip to content
Open
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
173 changes: 173 additions & 0 deletions src/html/comrak.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
/// <https://github.com/denoland/deno_doc/issues/633>.
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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(), "<p>This is a summary.</p>");
}

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

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

#[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(), "<p>First.</p>");
}

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

#[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(), "<p>Some <code>code.</code></p>");
}
}
Loading