| 416 |
416 |
|
|
| 417 |
417 |
|
let inline_only = self.inline_only;
|
| 418 |
418 |
|
let filtered = filtered.flat_map(move |event| match event {
|
| 419 |
|
- |
// The `</li>` is about to be unwrapped, taking the only thing that
|
| 420 |
|
- |
// separated one item's text from the next with it.
|
|
419 |
+ |
// The `</li>` is about to be unwrapped, taking one of the two things
|
|
420 |
+ |
// that separated one item's text from the next with it.
|
|
421 |
+ |
//
|
|
422 |
+ |
// MEASURED 2026-08-26 (infra `bb414371`): the other one holds
|
|
423 |
+ |
// without this, so what the break actually buys is a blank line
|
|
424 |
+ |
// rather than rescue from "onetwo". pulldown-cmark 0.13 writes its
|
|
425 |
+ |
// own newline between `</li>` and the next `<li>`, and ammonia
|
|
426 |
+ |
// leaves that newline behind when it unwraps the tags. Kept because
|
|
427 |
+ |
// the blank line is what the inline presets want, and pinned by
|
|
428 |
+ |
// `inline_only_separates_the_blocks_it_flattens` so a printer that
|
|
429 |
+ |
// stops writing that newline does not quietly take the separator
|
|
430 |
+ |
// with it.
|
| 421 |
431 |
|
Event::End(TagEnd::Item) if inline_only => {
|
| 422 |
432 |
|
vec![Event::End(TagEnd::Item), Event::SoftBreak]
|
| 423 |
433 |
|
}
|
| 424 |
434 |
|
// Same for `</p>` wherever the sanitize preset unwraps that too
|
| 425 |
|
- |
// (`phrase` does, `chat` does not). Without it two paragraphs fuse
|
| 426 |
|
- |
// into one word across the join. Harmless where `p` is kept: it is
|
|
435 |
+ |
// (`phrase` does, `chat` does not), and with the same correction as
|
|
436 |
+ |
// above: a blank line between the two paragraphs, not the only thing
|
|
437 |
+ |
// standing between them. Harmless where `p` is kept: it is
|
| 427 |
438 |
|
// whitespace between two block tags.
|
| 428 |
439 |
|
Event::End(TagEnd::Paragraph) if inline_only => {
|
| 429 |
440 |
|
vec![Event::End(TagEnd::Paragraph), Event::SoftBreak]
|
| 1052 |
1063 |
|
assert!(html.contains(r#"<h2 id="section-title">"#), "got: {html}");
|
| 1053 |
1064 |
|
}
|
| 1054 |
1065 |
|
|
|
1066 |
+ |
#[test]
|
|
1067 |
+ |
fn an_empty_heading_does_not_stall_the_injector() {
|
|
1068 |
+ |
// `##` with nothing after it is a Start immediately followed by an End,
|
|
1069 |
+ |
// so the inner scan stops on the very next event. Mutation found the
|
|
1070 |
+ |
// arithmetic that advances past a heading unobserved (infra
|
|
1071 |
+ |
// `bb414371`), and off by one in the low direction on this input means
|
|
1072 |
+ |
// the same heading is found forever.
|
|
1073 |
+ |
let html = Renderer::permissive()
|
|
1074 |
+ |
.with_heading_ids(true)
|
|
1075 |
+ |
.render("##\n\ntext");
|
|
1076 |
+ |
assert!(html.contains("text"), "got: {html}");
|
|
1077 |
+ |
}
|
|
1078 |
+ |
|
|
1079 |
+ |
#[test]
|
|
1080 |
+ |
fn a_headings_anchor_includes_its_inline_code() {
|
|
1081 |
+ |
// `extract_toc` reads code spans as part of the heading text, so the
|
|
1082 |
+ |
// injector has to as well or the two disagree exactly where API docs
|
|
1083 |
+ |
// live: `## Use `render_raw` now` would anchor as "use-now" while the
|
|
1084 |
+ |
// TOC pointed at "use-render_raw-now". Found by mutation (infra
|
|
1085 |
+ |
// `bb414371`): deleting the `Event::Code` arm passed the whole suite.
|
|
1086 |
+ |
let md = "## Use `render_raw` now";
|
|
1087 |
+ |
let html = Renderer::permissive().with_heading_ids(true).render(md);
|
|
1088 |
+ |
let toc = crate::extract_toc(md);
|
|
1089 |
+ |
assert_eq!(toc.len(), 1);
|
|
1090 |
+ |
assert!(
|
|
1091 |
+ |
html.contains(&format!(r#"id="{}""#, toc[0].anchor)),
|
|
1092 |
+ |
"TOC points at #{} but the heading carries something else: {html}",
|
|
1093 |
+ |
toc[0].anchor
|
|
1094 |
+ |
);
|
|
1095 |
+ |
assert!(
|
|
1096 |
+ |
html.contains("render_raw"),
|
|
1097 |
+ |
"the code span left the anchor: {html}"
|
|
1098 |
+ |
);
|
|
1099 |
+ |
}
|
|
1100 |
+ |
|
|
1101 |
+ |
#[test]
|
|
1102 |
+ |
fn inline_only_separates_the_blocks_it_flattens() {
|
|
1103 |
+ |
// `<li>` and `<p>` are unwrapped by the inline presets, and what is
|
|
1104 |
+ |
// left between two of them is whatever the HTML printer wrote. Measured
|
|
1105 |
+ |
// 2026-08-26 against pulldown-cmark 0.13: that is a single newline, and
|
|
1106 |
+ |
// these renderers add the second one. The separator is the point, so
|
|
1107 |
+ |
// pin it rather than the fusion the comment above warns about, which
|
|
1108 |
+ |
// the printer's own newline already prevents at this version.
|
|
1109 |
+ |
assert!(
|
|
1110 |
+ |
Renderer::phrase()
|
|
1111 |
+ |
.render("one\n\ntwo")
|
|
1112 |
+ |
.contains("one\n\ntwo"),
|
|
1113 |
+ |
"paragraphs lost their separator: {:?}",
|
|
1114 |
+ |
Renderer::phrase().render("one\n\ntwo")
|
|
1115 |
+ |
);
|
|
1116 |
+ |
assert!(
|
|
1117 |
+ |
Renderer::chat()
|
|
1118 |
+ |
.render("- one\n- two")
|
|
1119 |
+ |
.contains("one\n\ntwo"),
|
|
1120 |
+ |
"list items lost their separator: {:?}",
|
|
1121 |
+ |
Renderer::chat().render("- one\n- two")
|
|
1122 |
+ |
);
|
|
1123 |
+ |
}
|
|
1124 |
+ |
|
|
1125 |
+ |
#[test]
|
|
1126 |
+ |
fn a_block_renderer_does_not_get_the_inline_separators() {
|
|
1127 |
+ |
// The other direction: the extra break belongs to the presets that
|
|
1128 |
+ |
// unwrap the tag, and a renderer keeping `<p>` must not collect blank
|
|
1129 |
+ |
// lines it never asked for.
|
|
1130 |
+ |
for md in ["one\n\ntwo", "- one\n- two"] {
|
|
1131 |
+ |
let html = Renderer::permissive().render(md);
|
|
1132 |
+ |
assert!(
|
|
1133 |
+ |
!html.contains("\n\n"),
|
|
1134 |
+ |
"block rendering picked up an inline-only separator: {html:?}"
|
|
1135 |
+ |
);
|
|
1136 |
+ |
}
|
|
1137 |
+ |
}
|
|
1138 |
+ |
|
| 1055 |
1139 |
|
#[test]
|
| 1056 |
1140 |
|
fn every_toc_anchor_has_a_matching_heading_id() {
|
| 1057 |
1141 |
|
// The bug this fixes: TOC emitted href="#anchor" and nothing on the
|