Skip to content

Commit b0e8bdc

Browse files
committed
fix(html): backtrack jsdoc summary ending with ':' to last '.'
1 parent 89a6055 commit b0e8bdc

1 file changed

Lines changed: 173 additions & 0 deletions

File tree

src/html/comrak.rs

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,118 @@ fn walk_node_title<'a>(node: &'a AstNode<'a>) {
6363
}
6464
}
6565

66+
/// Append the inline plain-text contribution of `node` to `out`.
67+
/// Counts `NodeValue::Text` and `NodeValue::Code` literals, and treats
68+
/// `NodeValue::SoftBreak` as a single space. Other inline containers
69+
/// (Emph, Strong, Link, etc.) descend into their children.
70+
fn collect_inline_plain_text<'a>(node: &'a AstNode<'a>, out: &mut String) {
71+
match &node.data.borrow().value {
72+
NodeValue::Text(t) => out.push_str(t),
73+
NodeValue::Code(c) => out.push_str(&c.literal),
74+
NodeValue::SoftBreak => out.push(' '),
75+
_ => {
76+
for child in node.children() {
77+
collect_inline_plain_text(child, out);
78+
}
79+
}
80+
}
81+
}
82+
83+
/// Truncate the inline tree rooted at `node` so that, in document order,
84+
/// the cumulative plain-text contribution is exactly `target` bytes.
85+
/// Returns true once `*offset >= target`, signalling to callers that the
86+
/// rest of the tree should be detached.
87+
fn truncate_inline_to<'a>(
88+
node: &'a AstNode<'a>,
89+
offset: &mut usize,
90+
target: usize,
91+
) -> bool {
92+
if *offset >= target {
93+
return true;
94+
}
95+
96+
enum Kind {
97+
Text,
98+
Code,
99+
SoftBreak,
100+
Container,
101+
}
102+
103+
let kind = match &node.data.borrow().value {
104+
NodeValue::Text(_) => Kind::Text,
105+
NodeValue::Code(_) => Kind::Code,
106+
NodeValue::SoftBreak => Kind::SoftBreak,
107+
_ => Kind::Container,
108+
};
109+
110+
match kind {
111+
Kind::Text => {
112+
let mut data = node.data.borrow_mut();
113+
if let NodeValue::Text(t) = &mut data.value {
114+
let len = t.len();
115+
if *offset + len <= target {
116+
*offset += len;
117+
} else {
118+
let take = target - *offset;
119+
t.truncate(take);
120+
*offset = target;
121+
}
122+
}
123+
}
124+
Kind::Code => {
125+
let mut data = node.data.borrow_mut();
126+
if let NodeValue::Code(c) = &mut data.value {
127+
let len = c.literal.len();
128+
if *offset + len <= target {
129+
*offset += len;
130+
} else {
131+
let take = target - *offset;
132+
c.literal.truncate(take);
133+
*offset = target;
134+
}
135+
}
136+
}
137+
Kind::SoftBreak => {
138+
*offset += 1;
139+
}
140+
Kind::Container => {
141+
let children: Vec<_> = node.children().collect();
142+
let mut done = false;
143+
for child in children {
144+
if done {
145+
child.detach();
146+
} else {
147+
done = truncate_inline_to(child, offset, target);
148+
}
149+
}
150+
}
151+
}
152+
153+
*offset >= target
154+
}
155+
156+
/// If the first paragraph's trimmed plain text ends with `':'` and
157+
/// contains a `'.'`, truncate the paragraph so the rendered output ends
158+
/// right after the last `'.'`. Does nothing otherwise. See
159+
/// <https://github.com/denoland/deno_doc/issues/633>.
160+
fn truncate_title_summary_trailing_colon<'a>(paragraph: &'a AstNode<'a>) {
161+
let mut plain = String::new();
162+
collect_inline_plain_text(paragraph, &mut plain);
163+
164+
let trimmed = plain.trim_end_matches(|c: char| c.is_ascii_whitespace());
165+
if !trimmed.ends_with(':') {
166+
return;
167+
}
168+
169+
let Some(last_dot) = trimmed.rfind('.') else {
170+
return;
171+
};
172+
173+
let target = last_dot + 1;
174+
let mut offset = 0;
175+
truncate_inline_to(paragraph, &mut offset, target);
176+
}
177+
66178
pub fn render_node<'a>(
67179
node: &'a AstNode<'a>,
68180
options: &comrak::Options,
@@ -152,6 +264,7 @@ pub fn create_renderer(
152264
walk_node_title(root);
153265

154266
if let Some(child) = root.first_child() {
267+
truncate_title_summary_trailing_colon(child);
155268
render_node(child, &options, &plugins)
156269
} else {
157270
return None;
@@ -254,3 +367,63 @@ impl SyntaxHighlighterAdapter for ComrakHighlightWrapperAdapter {
254367
}
255368
}
256369
}
370+
371+
#[cfg(test)]
372+
mod tests {
373+
use super::*;
374+
375+
fn render_title_only(md: &str) -> String {
376+
let mut options = default_options();
377+
options.render.escape = true;
378+
379+
let arena = Arena::new();
380+
let root = comrak::parse_document(&arena, md, &options);
381+
walk_node_title(root);
382+
383+
let Some(child) = root.first_child() else {
384+
return String::new();
385+
};
386+
truncate_title_summary_trailing_colon(child);
387+
render_node(child, &options, &comrak::Plugins::default())
388+
}
389+
390+
#[test]
391+
fn title_summary_trailing_colon_with_period_truncates_to_last_period() {
392+
let html = render_title_only("This is a summary. It says:");
393+
assert_eq!(html.trim(), "<p>This is a summary.</p>");
394+
}
395+
396+
#[test]
397+
fn title_summary_trailing_colon_without_period_left_unchanged() {
398+
let html = render_title_only("This is a summary:");
399+
assert_eq!(html.trim(), "<p>This is a summary:</p>");
400+
}
401+
402+
#[test]
403+
fn title_summary_no_trailing_colon_left_unchanged() {
404+
let html = render_title_only("This is a summary.");
405+
assert_eq!(html.trim(), "<p>This is a summary.</p>");
406+
}
407+
408+
#[test]
409+
fn title_summary_trailing_colon_after_code_span_truncates() {
410+
// Period before the code+colon tail — backtracking should drop the
411+
// code span and trailing colon entirely.
412+
let html = render_title_only("First. Then `something`:");
413+
assert_eq!(html.trim(), "<p>First.</p>");
414+
}
415+
416+
#[test]
417+
fn title_summary_trailing_colon_after_emph_truncates() {
418+
let html = render_title_only("First. Then *emph*:");
419+
assert_eq!(html.trim(), "<p>First.</p>");
420+
}
421+
422+
#[test]
423+
fn title_summary_period_inside_code_span_keeps_code_up_to_period() {
424+
// The last '.' lives inside a code span; truncation lands exactly at
425+
// the end of that span and drops the following text+colon.
426+
let html = render_title_only("Some `code.` text:");
427+
assert_eq!(html.trim(), "<p>Some <code>code.</code></p>");
428+
}
429+
}

0 commit comments

Comments
 (0)