Skip to main content

mbr_core/
html.rs

1// Copyright 2015 Google Inc. All rights reserved.
2//
3// Permission is hereby granted, free of charge, to any person obtaining a copy
4// of this software and associated documentation files (the "Software"), to deal
5// in the Software without restriction, including without limitation the rights
6// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7// copies of the Software, and to permit persons to whom the Software is
8// furnished to do so, subject to the following conditions:
9//
10// The above copyright notice and this permission notice shall be included in
11// all copies or substantial portions of the Software.
12//
13// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19// THE SOFTWARE.
20
21//! # MBR HTML Renderer
22//!
23//! Modified from pulldown-cmark's html.rs to support MBR-specific features.
24//!
25//! ## MBR Extensions
26//!
27//! All extensions are controlled via [`HtmlConfig`] and can be enabled/disabled:
28//!
29//! | Extension | Config Flag | Description |
30//! |-----------|-------------|-------------|
31//! | **Section wrapping** | `enable_sections` | Wraps content in `<section>` tags with `<hr>` as dividers |
32//! | **Mermaid diagrams** | `enable_mermaid` | Renders \`\`\`mermaid blocks as `<pre class="mermaid">` |
33//!
34//! ## Usage
35//!
36//! ```rust,ignore
37//! // Use MBR defaults (all extensions enabled)
38//! html::push_html_mbr(&mut output, events);
39//!
40//! // Or configure explicitly
41//! let config = HtmlConfig { enable_sections: true, enable_mermaid: false };
42//! html::push_html_with_config(&mut output, events, config);
43//! ```
44//!
45//! ## Upstream Tracking
46//!
47//! Based on: <https://github.com/pulldown-cmark/pulldown-cmark/blob/master/pulldown-cmark/src/html.rs>
48//!
49//! Key differences from upstream:
50//! - Added [`HtmlConfig`] for extension configuration
51//! - Added `section_started` field for section tracking
52//! - Added `container_depth` field so section boundaries are only emitted at
53//!   top level (a `</section>` inside a blockquote or list mis-nests)
54//! - Added `codeblock_state` field for mermaid closing tag handling
55//! - Link and image destinations with a script-capable scheme (`javascript:`,
56//!   `vbscript:`, non-image `data:`) are replaced with an inert value
57//! - Images are emitted with `loading="lazy" decoding="async"`
58//! - Removed `ContainerBlock` handling (not used in MBR)
59//!
60//! Output is pinned by a golden corpus in `tests/render_golden.rs`.
61
62use std::collections::HashMap;
63
64use crate::attrs::ParsedAttrs;
65use crate::tasks::{Annotations, TaskPriority, TaskStatus};
66use chrono::NaiveDateTime;
67use pulldown_cmark_escape::IoWriter;
68use pulldown_cmark_escape::{FmtWriter, StrWrite, escape_href, escape_html, escape_html_body_text};
69
70use pulldown_cmark::{
71    Alignment, BlockQuoteKind, CodeBlockKind, CowStr,
72    Event::{self, *},
73    LinkType, Tag, TagEnd,
74};
75
76// ============================================================================
77// MBR EXTENSION: Configuration
78// ============================================================================
79
80/// The source line one block-level element was written on, addressed by the
81/// element's `Start` event's index in the stream handed to the writer.
82///
83/// Built by [`crate::markdown`], which is the only place the parser's byte
84/// offsets exist. See `markdown::BlockLines` for the table and for why the
85/// index survives the three render passes.
86#[derive(Debug, Clone, Copy)]
87pub struct BlockLine {
88    /// Index of the block's `Start` event in the event stream.
89    pub at: u32,
90    /// 1-based source line the block starts on.
91    pub line: u32,
92}
93
94/// Configuration for MBR-specific HTML extensions.
95///
96/// This struct controls all custom behavior beyond pulldown-cmark's standard
97/// HTML output. Use [`HtmlConfig::mbr_defaults()`] for standard MBR behavior,
98/// or construct manually for fine-grained control.
99#[derive(Debug, Default, Clone)]
100pub struct HtmlConfig {
101    /// Wrap content in `<section>` tags, using `<hr>` as dividers.
102    ///
103    /// When enabled:
104    /// - Output starts with `<section>\n`
105    /// - Each **top-level** `---` (Rule) becomes `</section>\n<hr />\n<section>\n`
106    /// - A `---` nested inside a blockquote, list item, footnote definition,
107    ///   table cell, or definition renders as a plain `<hr />`: splitting the
108    ///   section there would close it outside its container and eject the
109    ///   remaining content from the quote/list on reparse
110    /// - Output ends with `</section>\n`
111    pub enable_sections: bool,
112
113    /// Render \`\`\`mermaid code blocks as `<pre class="mermaid">` without
114    /// the `<code>` wrapper, allowing mermaid.js to render diagrams directly.
115    pub enable_mermaid: bool,
116
117    /// Attributes for each section (by 1-based index).
118    ///
119    /// Section 0 is the first section (before any `---`), section 1 is after the
120    /// first `---`, etc. When rendering, attributes are applied to the `<section>` tag.
121    ///
122    /// Use syntax like `--- {#id .class data-attr="value"}` in markdown to set attrs.
123    pub section_attrs: HashMap<usize, ParsedAttrs>,
124
125    /// Source lines for block-level elements, ascending by `at`.
126    ///
127    /// **Empty means the feature is off**, which is what every caller that does
128    /// not opt in gets: `HtmlConfig::default()` and [`HtmlConfig::mbr_defaults`]
129    /// both leave it empty, so the writer's output is byte-for-byte what it was
130    /// before `data-mbr-line` existed.
131    ///
132    /// When non-empty, the writer emits ` data-mbr-line="N"` on the eight block
133    /// tags listed in `markdown::is_review_block_start` — the single definition
134    /// of that set, which the arms here must not drift from — but only on the
135    /// events the table actually names. A `ReviewLines::TermsOnly` table names
136    /// only `<dt>` starts, so every other arm writes nothing.
137    pub block_lines: Vec<BlockLine>,
138}
139
140impl HtmlConfig {
141    /// Standard MBR configuration with all extensions enabled and no section attrs.
142    pub fn mbr_defaults() -> Self {
143        Self {
144            enable_sections: true,
145            enable_mermaid: true,
146            section_attrs: HashMap::new(),
147            block_lines: Vec::new(),
148        }
149    }
150
151    /// Standard MBR configuration with section attributes.
152    pub fn mbr_with_section_attrs(section_attrs: HashMap<usize, ParsedAttrs>) -> Self {
153        Self {
154            enable_sections: true,
155            enable_mermaid: true,
156            section_attrs,
157            block_lines: Vec::new(),
158        }
159    }
160
161    /// Standard MBR configuration with section attributes and `data-mbr-line`
162    /// source lines.
163    ///
164    /// An empty `block_lines` is exactly [`HtmlConfig::mbr_with_section_attrs`],
165    /// so a caller that computed no lines pays nothing and emits nothing.
166    pub fn mbr_with_attrs_and_block_lines(
167        section_attrs: HashMap<usize, ParsedAttrs>,
168        block_lines: Vec<BlockLine>,
169    ) -> Self {
170        Self {
171            enable_sections: true,
172            enable_mermaid: true,
173            section_attrs,
174            block_lines,
175        }
176    }
177}
178
179// ============================================================================
180// Internal Types
181// ============================================================================
182
183enum TableState {
184    Head,
185    Body,
186}
187
188struct HtmlWriter<'a, I, W> {
189    /// Iterator supplying events.
190    iter: I,
191
192    /// Writer to write to.
193    writer: W,
194
195    /// Whether or not the last write wrote a newline.
196    end_newline: bool,
197
198    /// Whether if inside a metadata block (text should not be written)
199    in_non_writing_block: bool,
200
201    // MBR EXTENSION: Mermaid support - tracks the closing tag for code blocks
202    codeblock_state: Option<CowStr<'a>>,
203
204    table_state: TableState,
205    table_alignments: Vec<Alignment>,
206    table_cell_index: usize,
207    numbers: HashMap<CowStr<'a>, usize>,
208
209    // MBR EXTENSION: Configuration for extensions
210    config: HtmlConfig,
211
212    // MBR EXTENSION: Section wrapping - tracks if opening section was emitted
213    section_started: bool,
214
215    // MBR EXTENSION: Section attributes - tracks current section index (0-based)
216    current_section: usize,
217
218    // MBR EXTENSION: Section wrapping - how many block containers (blockquote,
219    // list, list item, table cell, footnote definition, definition list) are
220    // currently open. A `<section>` boundary may only be emitted at depth 0.
221    container_depth: usize,
222
223    // MBR EXTENSION: `data-mbr-line` - how many events have been pulled off
224    // `iter`, which is the key `config.block_lines` is addressed by. Maintained
225    // by `next_event` and by nothing else; see that method.
226    event_index: usize,
227
228    // MBR EXTENSION: `data-mbr-line` - cursor into `config.block_lines`. The
229    // writer's lookups are non-decreasing (it walks the stream front to back),
230    // so this walks rather than binary-searching, exactly like
231    // `markdown::TextLineCursor`.
232    block_line_next: usize,
233
234    // MBR EXTENSION: `data-mbr-line` - hoisted `!config.block_lines.is_empty()`
235    // so the off path (every build, CLI and QuickLook render) is one branch.
236    emit_block_lines: bool,
237}
238
239/// Block containers that a `<section>` boundary must never be emitted inside.
240///
241/// Splitting a section in the middle of one of these emits `</section>` before
242/// the container's own closing tag, which a browser resolves by hoisting the
243/// remaining content out of the quote/list entirely.
244fn is_block_container(tag: &Tag<'_>) -> bool {
245    matches!(
246        tag,
247        Tag::BlockQuote(_)
248            | Tag::List(_)
249            | Tag::Item
250            | Tag::TableCell
251            | Tag::FootnoteDefinition(_)
252            | Tag::DefinitionList
253            | Tag::DefinitionListDefinition
254    )
255}
256
257/// [`is_block_container`] for the closing half of the event stream.
258fn is_block_container_end(tag: &TagEnd) -> bool {
259    matches!(
260        tag,
261        TagEnd::BlockQuote(_)
262            | TagEnd::List(_)
263            | TagEnd::Item
264            | TagEnd::TableCell
265            | TagEnd::FootnoteDefinition
266            | TagEnd::DefinitionList
267            | TagEnd::DefinitionListDefinition
268    )
269}
270
271// ============================================================================
272// MBR EXTENSION: Task list markup
273// ============================================================================
274
275/// Closing tag for the span [`task_text_open`] opens.
276pub(crate) const TASK_TEXT_CLOSE: &str = "</span>";
277
278/// Renders the checkbox that stands in for a markdown task marker.
279///
280/// `line` is the 1-based source line the checkbox was written on. It is `Some`
281/// on the document render path, where [`crate::markdown`] recovers it from the
282/// parser's byte offsets, and `None` on the raw [`push_html_mbr`] path, which
283/// has no source to measure against.
284///
285/// The checkbox is `disabled` in **every** mode — server, GUI, static build and
286/// QuickLook alike. Making it live is a frontend concern: a later phase removes
287/// the attribute at runtime when edit mode is on, which keeps config plumbing
288/// out of the render path and keeps a static build byte-identical to a served
289/// page.
290pub(crate) fn task_checkbox_html(status: TaskStatus, line: Option<u32>) -> String {
291    // Long enough for the widest form (`canceled`, four-digit line) without a
292    // reallocation.
293    let mut html = String::with_capacity(128);
294    html.push_str("<input type=\"checkbox\" class=\"mbr-task-check\"");
295    if let Some(line) = line {
296        // `id` is the anchor the task browser deep-links to (`#mbr-task-42`);
297        // `data-mbr-task-line` is what a line patch sends back to the server.
298        let _ = write!(
299            html,
300            " id=\"mbr-task-{line}\" data-mbr-task-line=\"{line}\""
301        );
302    }
303    // `checked` alone cannot distinguish canceled from open, so the status is
304    // also written out for CSS and for the toggle handler.
305    let _ = write!(
306        html,
307        " data-mbr-task-status=\"{}\"",
308        task_status_slug(status)
309    );
310    if status == TaskStatus::Done {
311        html.push_str(" checked");
312    }
313    html.push_str(" disabled>");
314    html
315}
316
317/// Opening tag for the span wrapping a task's display text.
318///
319/// The status class goes here rather than on the `<li>` so that a canceled
320/// parent task strikes through its own text only, leaving its subtasks and its
321/// annotation chips untouched. (`<li>` carries no class at all: pulldown-cmark
322/// gives the writer no attribute channel for list items, and CSS reaches it
323/// with `:has()` — see the task rules in `templates/theme.css`.)
324pub(crate) const fn task_text_open(status: TaskStatus) -> &'static str {
325    match status {
326        TaskStatus::Canceled => "<span class=\"mbr-task-text mbr-task-canceled\">",
327        TaskStatus::Open | TaskStatus::Done => "<span class=\"mbr-task-text\">",
328    }
329}
330
331/// Renders the annotations stripped out of a task's text as chips.
332///
333/// Returns an empty string for a task with no annotations, which is the common
334/// case and the one worth not allocating for.
335pub(crate) fn task_annotations_html(annotations: &Annotations) -> String {
336    let mut html = String::new();
337
338    if let Some((class, label)) = task_priority_chip(annotations.priority) {
339        // Empty content on purpose: the dot is drawn by CSS, so the label has
340        // to reach a screen reader some other way.
341        let _ = write!(
342            html,
343            " <span class=\"mbr-task-pri {class}\" role=\"img\" aria-label=\"{label}\" title=\"{label}\"></span>"
344        );
345    }
346    for tag in &annotations.tags {
347        html.push_str(" <span class=\"mbr-task-tag\">#");
348        // The tag grammar is `[A-Za-z0-9_-]+`, so this can never change a byte.
349        // It stays because markup assembled by hand is exactly where a later
350        // grammar widening turns into an injection.
351        let _ = escape_html(&mut html, tag);
352        html.push_str("</span>");
353    }
354    if let Some(due) = annotations.due {
355        push_task_time(&mut html, "mbr-task-due", due, annotations.due_has_time);
356    }
357    if let Some(done) = annotations.done {
358        push_task_time(
359            &mut html,
360            "mbr-task-completed",
361            done,
362            annotations.done_has_time,
363        );
364    }
365    if let Some(moved_to) = annotations.moved_to {
366        push_task_time(
367            &mut html,
368            "mbr-task-moved",
369            moved_to.and_time(chrono::NaiveTime::MIN),
370            false,
371        );
372    }
373
374    html
375}
376
377/// The value written to `data-mbr-task-status`.
378const fn task_status_slug(status: TaskStatus) -> &'static str {
379    match status {
380        TaskStatus::Open => "open",
381        TaskStatus::Done => "done",
382        TaskStatus::Canceled => "canceled",
383    }
384}
385
386/// Modifier class and accessible label for a priority dot, or `None` for the
387/// default priority, which draws nothing.
388const fn task_priority_chip(priority: TaskPriority) -> Option<(&'static str, &'static str)> {
389    match priority {
390        TaskPriority::Normal => None,
391        TaskPriority::High => Some(("mbr-task-pri-high", "High priority")),
392        TaskPriority::Urgent => Some(("mbr-task-pri-urgent", "Urgent priority")),
393    }
394}
395
396/// Appends one `<time>` chip.
397///
398/// The visible label deliberately omits the year: a chip reading "Aug 5" is
399/// legible at a glance, and the unabbreviated value is already in `datetime`
400/// for anything that needs to compute with it. Nothing here is relative to
401/// *today* — a server render is cached and a static build is frozen, so an
402/// "overdue" class baked in at render time would be a lie by tomorrow. Marking
403/// overdue is left to the frontend, which knows what day it is.
404fn push_task_time(html: &mut String, class: &str, value: NaiveDateTime, has_time: bool) {
405    let (attr, label) = if has_time {
406        (
407            value.format("%Y-%m-%dT%H:%M").to_string(),
408            value.format("%b %-d, %-I:%M %p").to_string(),
409        )
410    } else {
411        (
412            value.format("%Y-%m-%d").to_string(),
413            value.format("%b %-d").to_string(),
414        )
415    };
416    let _ = write!(
417        html,
418        " <time class=\"{class}\" datetime=\"{attr}\">{label}</time>"
419    );
420}
421
422/// Opens the horizontal scroll container every markdown table is wrapped in
423/// (closed after `</table>`). `theme.css` styles `.mbr-table-scroll`; a
424/// repository's own theme can use the same hook.
425const TABLE_SCROLL_OPEN: &str = "<div class=\"mbr-table-scroll\">";
426
427// ============================================================================
428// MBR EXTENSION: Destination scheme filtering
429// ============================================================================
430
431/// Written in place of a link or image destination whose URL scheme can execute
432/// script. The element and its text survive; only the navigation is made inert.
433const BLOCKED_DESTINATION: &str = "about:blank#blocked";
434
435/// Length of the longest scheme [`is_blocked_destination`] rejects (`javascript`).
436const MAX_BLOCKED_SCHEME_LEN: usize = 10;
437
438/// True when `dest` carries a URL scheme that can execute script.
439///
440/// Markdown is routinely authored by someone other than the person serving it,
441/// so `[click me](javascript:…)` must not become a live href. `data:` is rejected
442/// too, except for `data:image/*` — inline images are legitimate (and preserved
443/// verbatim by `link_transform`), whereas `data:text/html` is a script vector.
444///
445/// The scheme is normalized the way a browser normalizes one before resolving a
446/// URL: leading C0 control characters and spaces are ignored, and embedded tab,
447/// line feed, and carriage return characters are removed anywhere they appear.
448/// Without that, `" java\tscript:alert(1)"` would slip through while still
449/// navigating.
450fn is_blocked_destination(dest: &str) -> bool {
451    let mut scheme = [0u8; MAX_BLOCKED_SCHEME_LEN];
452    let mut len = 0usize;
453    let mut found_colon = false;
454
455    for c in dest.trim_start_matches(|c: char| c <= ' ').chars() {
456        match c {
457            '\t' | '\n' | '\r' => continue,
458            ':' => {
459                found_colon = true;
460                break;
461            }
462            _ if c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.') => {
463                if len == MAX_BLOCKED_SCHEME_LEN {
464                    // Longer than every scheme we reject, so it cannot match.
465                    return false;
466                }
467                scheme[len] = c.to_ascii_lowercase() as u8;
468                len += 1;
469            }
470            // Not a scheme character: the destination is relative or malformed.
471            _ => return false,
472        }
473    }
474
475    if !found_colon {
476        return false;
477    }
478
479    match &scheme[..len] {
480        b"javascript" | b"vbscript" => true,
481        b"data" => !is_inline_image_data_url(dest),
482        _ => false,
483    }
484}
485
486/// True for `data:image/…` destinations, the only `data:` shape MBR allows.
487fn is_inline_image_data_url(dest: &str) -> bool {
488    let Some((_, payload)) = dest.split_once(':') else {
489        return false;
490    };
491    let mut actual = payload
492        .chars()
493        .filter(|c| !matches!(c, '\t' | '\n' | '\r'))
494        .map(|c| c.to_ascii_lowercase());
495    "image/"
496        .chars()
497        .all(|expected| actual.next() == Some(expected))
498}
499
500impl<'a, I, W> HtmlWriter<'a, I, W>
501where
502    I: Iterator<Item = Event<'a>>,
503    W: StrWrite,
504{
505    fn new(iter: I, writer: W) -> Self {
506        Self::new_with_config(iter, writer, HtmlConfig::default())
507    }
508
509    fn new_with_config(iter: I, writer: W, config: HtmlConfig) -> Self {
510        let emit_block_lines = !config.block_lines.is_empty();
511        Self {
512            iter,
513            writer,
514            end_newline: true,
515            in_non_writing_block: false,
516            codeblock_state: None,
517            table_state: TableState::Head,
518            table_alignments: vec![],
519            table_cell_index: 0,
520            numbers: HashMap::new(),
521            config,
522            section_started: false,
523            current_section: 0,
524            container_depth: 0,
525            event_index: 0,
526            block_line_next: 0,
527            emit_block_lines,
528        }
529    }
530
531    /// MBR EXTENSION: the **only** way to pull an event off `iter`.
532    ///
533    /// # Why this must be the only way
534    ///
535    /// `config.block_lines` is keyed by an event's position in the stream, so
536    /// the counter has to see every event the writer consumes — and the writer
537    /// consumes events from *two* loops, not one: [`Self::run`] and
538    /// [`Self::raw_text`], which drains an image's inner events into its `alt`
539    /// attribute. A counter incremented in `run` alone would desynchronise
540    /// permanently after the first image on the page, and every `data-mbr-line`
541    /// after it would name the wrong source line — with well-formed HTML, no
542    /// panic and no error anywhere. `block_line_index_survives_image_alt_text`
543    /// pins this.
544    #[inline]
545    fn next_event(&mut self) -> Option<Event<'a>> {
546        let event = self.iter.next();
547        if event.is_some() {
548            self.event_index += 1;
549        }
550        event
551    }
552
553    /// MBR EXTENSION: writes ` data-mbr-line="N"` for the event currently being
554    /// rendered, when the table has a line for it.
555    ///
556    /// [`Self::event_index`] is post-incremented by [`Self::next_event`], so the
557    /// event in hand sits at `event_index - 1`. That off-by-one is hidden here
558    /// rather than repeated at each of the eight call sites.
559    fn write_block_line(&mut self) -> Result<(), W::Error> {
560        let index = match self.event_index.checked_sub(1) {
561            Some(index) => index,
562            // Unreachable: a tag is only written for an event already pulled.
563            None => return Ok(()),
564        };
565        // Advance the cursor past everything the writer has already gone by.
566        while self
567            .config
568            .block_lines
569            .get(self.block_line_next)
570            .is_some_and(|entry| (entry.at as usize) < index)
571        {
572            self.block_line_next += 1;
573        }
574        // Copied out before `self.write`, which borrows `self` mutably.
575        let line = self
576            .config
577            .block_lines
578            .get(self.block_line_next)
579            .filter(|entry| entry.at as usize == index)
580            .map(|entry| entry.line);
581        match line {
582            Some(line) => {
583                self.write(" data-mbr-line=\"")?;
584                write!(&mut self.writer, "{}", line)?;
585                self.write("\"")
586            }
587            None => Ok(()),
588        }
589    }
590
591    /// Writes a new line.
592    #[inline]
593    fn write_newline(&mut self) -> Result<(), W::Error> {
594        self.end_newline = true;
595        self.writer.write_str("\n")
596    }
597
598    /// Writes a buffer, and tracks whether or not a newline was written.
599    #[inline]
600    fn write(&mut self, s: &str) -> Result<(), W::Error> {
601        self.writer.write_str(s)?;
602
603        if !s.is_empty() {
604            self.end_newline = s.ends_with('\n');
605        }
606        Ok(())
607    }
608
609    /// MBR EXTENSION: Writes an `href`/`src` value, substituting
610    /// [`BLOCKED_DESTINATION`] for schemes that can execute script.
611    #[inline]
612    fn write_destination(&mut self, dest: &str) -> Result<(), W::Error> {
613        if is_blocked_destination(dest) {
614            self.write(BLOCKED_DESTINATION)
615        } else {
616            escape_href(&mut self.writer, dest)
617        }
618    }
619
620    /// MBR EXTENSION: opens a `<pre>`, carrying `data-mbr-line` when the feature
621    /// is on. With it off this writes exactly `<pre>`, so the three code-block
622    /// shapes stay byte-identical to what they were.
623    fn write_pre_open(&mut self) -> Result<(), W::Error> {
624        if self.emit_block_lines {
625            self.write("<pre")?;
626            self.write_block_line()?;
627            self.write(">")
628        } else {
629            self.write("<pre>")
630        }
631    }
632
633    fn run(mut self) -> Result<(), W::Error> {
634        // MBR EXTENSION: Emit opening section tag with optional attrs
635        if self.config.enable_sections {
636            let attrs_str = self
637                .config
638                .section_attrs
639                .get(&self.current_section)
640                .map(|a| a.to_html_attr_string())
641                .unwrap_or_default();
642            self.write(&format!("<section{}>\n", attrs_str))?;
643            self.section_started = true;
644        }
645
646        // MBR EXTENSION: `next_event`, never `self.iter.next()` — see that
647        // method for why the counter must see every consumed event.
648        while let Some(event) = self.next_event() {
649            match event {
650                Start(tag) => {
651                    self.start_tag(tag)?;
652                }
653                End(tag) => {
654                    self.end_tag(tag)?;
655                }
656                Text(text) => {
657                    if !self.in_non_writing_block {
658                        escape_html_body_text(&mut self.writer, &text)?;
659                        self.end_newline = text.ends_with('\n');
660                    }
661                }
662                Code(text) => {
663                    self.write("<code>")?;
664                    escape_html_body_text(&mut self.writer, &text)?;
665                    self.write("</code>")?;
666                }
667                InlineMath(text) => {
668                    self.write(r#"<span class="math math-inline">"#)?;
669                    escape_html(&mut self.writer, &text)?;
670                    self.write("</span>")?;
671                }
672                DisplayMath(text) => {
673                    self.write(r#"<span class="math math-display">"#)?;
674                    escape_html(&mut self.writer, &text)?;
675                    self.write("</span>")?;
676                }
677                Html(html) | InlineHtml(html) => {
678                    self.write(&html)?;
679                }
680                SoftBreak => {
681                    self.write_newline()?;
682                }
683                HardBreak => {
684                    self.write("<br />\n")?;
685                }
686                // MBR EXTENSION: Section dividers with optional attrs
687                Rule => {
688                    // The section index advances for every rule, nested or not, so
689                    // that it stays aligned with the attribute map that
690                    // `markdown.rs` builds by counting `Event::Rule` the same way.
691                    if self.config.enable_sections {
692                        self.current_section += 1;
693                    }
694
695                    if self.config.enable_sections && self.container_depth == 0 {
696                        let attrs_str = self
697                            .config
698                            .section_attrs
699                            .get(&self.current_section)
700                            .map(|a| a.to_html_attr_string())
701                            .unwrap_or_default();
702                        self.write(&format!("</section>\n<hr />\n<section{}>\n", attrs_str))?;
703                    } else {
704                        // Standard pulldown-cmark behavior. Also used for rules
705                        // nested inside a blockquote/list/footnote: closing the
706                        // section there would close outside the container and
707                        // eject everything after the rule from it.
708                        if self.end_newline {
709                            self.write("<hr />\n")?;
710                        } else {
711                            self.write("\n<hr />\n")?;
712                        }
713                    }
714                }
715                FootnoteReference(name) => {
716                    let len = self.numbers.len() + 1;
717                    self.write("<sup class=\"footnote-reference\"><a href=\"#")?;
718                    escape_html(&mut self.writer, &name)?;
719                    self.write("\">")?;
720                    let number = *self.numbers.entry(name).or_insert(len);
721                    write!(&mut self.writer, "{}", number)?;
722                    self.write("</a></sup>")?;
723                }
724                // MBR EXTENSION: the checkbox carries mbr's task classes so the
725                // theme styles it the same way here as on the document render
726                // path. It cannot carry a source line: this writer is handed an
727                // event stream, not the markdown it came from.
728                // `markdown::collect_events_and_headings` replaces the marker
729                // with a line-numbered checkbox before the events ever reach
730                // here, so in the application this arm only runs for callers of
731                // `push_html_mbr` that skip that pass.
732                TaskListMarker(checked) => {
733                    let status = if checked {
734                        TaskStatus::Done
735                    } else {
736                        TaskStatus::Open
737                    };
738                    self.write(&task_checkbox_html(status, None))?;
739                    self.write("\n")?;
740                }
741            }
742        }
743
744        // MBR EXTENSION: Emit closing section tag
745        if self.config.enable_sections && self.section_started {
746            self.write("</section>\n")?;
747        }
748
749        Ok(())
750    }
751
752    /// Writes the start of an HTML tag.
753    fn start_tag(&mut self, tag: Tag<'a>) -> Result<(), W::Error> {
754        // MBR EXTENSION: Section wrapping - track open block containers.
755        if is_block_container(&tag) {
756            self.container_depth += 1;
757        }
758        match tag {
759            Tag::HtmlBlock => Ok(()),
760            // MBR EXTENSION: `data-mbr-line`. One of the eight tags in
761            // `markdown::is_review_block_start`; keep the two sets in step.
762            Tag::Paragraph => {
763                if self.emit_block_lines {
764                    self.write(if self.end_newline { "<p" } else { "\n<p" })?;
765                    self.write_block_line()?;
766                    self.write(">")
767                } else if self.end_newline {
768                    self.write("<p>")
769                } else {
770                    self.write("\n<p>")
771                }
772            }
773            Tag::Heading {
774                level,
775                id,
776                classes,
777                attrs,
778            } => {
779                if self.end_newline {
780                    self.write("<")?;
781                } else {
782                    self.write("\n<")?;
783                }
784                write!(&mut self.writer, "{}", level)?;
785                // MBR EXTENSION: `data-mbr-line`, emitted after the level and
786                // *before* the author's own id/classes/attrs. An HTML parser
787                // keeps the FIRST occurrence of a duplicate attribute, so going
788                // first is what makes ours authoritative against a heading
789                // written as `## Title {data-mbr-line=99}`.
790                if self.emit_block_lines {
791                    self.write_block_line()?;
792                }
793                if let Some(id) = id {
794                    self.write(" id=\"")?;
795                    escape_html(&mut self.writer, &id)?;
796                    self.write("\"")?;
797                }
798                let mut classes = classes.iter();
799                if let Some(class) = classes.next() {
800                    self.write(" class=\"")?;
801                    escape_html(&mut self.writer, class)?;
802                    for class in classes {
803                        self.write(" ")?;
804                        escape_html(&mut self.writer, class)?;
805                    }
806                    self.write("\"")?;
807                }
808                for (attr, value) in attrs {
809                    self.write(" ")?;
810                    escape_html(&mut self.writer, &attr)?;
811                    if let Some(val) = value {
812                        self.write("=\"")?;
813                        escape_html(&mut self.writer, &val)?;
814                        self.write("\"")?;
815                    } else {
816                        self.write("=\"\"")?;
817                    }
818                }
819                self.write(">")
820            }
821            // MBR EXTENSION: `data-mbr-line` goes on the `<table>`, not on the
822            // cells: every cell in a row shares one source line, so per-cell
823            // attributes triple the byte cost of a table-heavy document to say
824            // the same thing.
825            //
826            // MBR EXTENSION: every table is wrapped in `div.mbr-table-scroll`,
827            // the horizontal scroll container theme.css styles, so a wide table
828            // scrolls inside itself instead of widening the page on a phone. A
829            // wrapper rather than `display: block` on the table, because WebKit
830            // drops a block-displayed table's semantics for VoiceOver. The line
831            // stays on the `<table>`: it is the block a review note anchors to.
832            Tag::Table(alignments) => {
833                self.table_alignments = alignments;
834                self.write(TABLE_SCROLL_OPEN)?;
835                if self.emit_block_lines {
836                    self.write("<table")?;
837                    self.write_block_line()?;
838                    self.write(">")
839                } else {
840                    self.write("<table>")
841                }
842            }
843            Tag::TableHead => {
844                self.table_state = TableState::Head;
845                self.table_cell_index = 0;
846                self.write("<thead><tr>")
847            }
848            Tag::TableRow => {
849                self.table_cell_index = 0;
850                self.write("<tr>")
851            }
852            Tag::TableCell => {
853                match self.table_state {
854                    TableState::Head => {
855                        self.write("<th")?;
856                    }
857                    TableState::Body => {
858                        self.write("<td")?;
859                    }
860                }
861                match self.table_alignments.get(self.table_cell_index) {
862                    Some(&Alignment::Left) => self.write(" style=\"text-align: left\">"),
863                    Some(&Alignment::Center) => self.write(" style=\"text-align: center\">"),
864                    Some(&Alignment::Right) => self.write(" style=\"text-align: right\">"),
865                    _ => self.write(">"),
866                }
867            }
868            Tag::BlockQuote(kind) => {
869                let class_str = match kind {
870                    None => "",
871                    Some(kind) => match kind {
872                        BlockQuoteKind::Note => " class=\"markdown-alert-note\"",
873                        BlockQuoteKind::Tip => " class=\"markdown-alert-tip\"",
874                        BlockQuoteKind::Important => " class=\"markdown-alert-important\"",
875                        BlockQuoteKind::Warning => " class=\"markdown-alert-warning\"",
876                        BlockQuoteKind::Caution => " class=\"markdown-alert-caution\"",
877                    },
878                };
879                // MBR EXTENSION: `data-mbr-line`.
880                if self.emit_block_lines {
881                    self.write(if self.end_newline {
882                        "<blockquote"
883                    } else {
884                        "\n<blockquote"
885                    })?;
886                    self.write(class_str)?;
887                    self.write_block_line()?;
888                    self.write(">\n")
889                } else if self.end_newline {
890                    self.write(&format!("<blockquote{}>\n", class_str))
891                } else {
892                    self.write(&format!("\n<blockquote{}>\n", class_str))
893                }
894            }
895            Tag::CodeBlock(info) => {
896                if !self.end_newline {
897                    self.write_newline()?;
898                }
899                self.codeblock_state = Some("</code></pre>".into());
900                // MBR EXTENSION: `data-mbr-line` belongs on the `<pre>` in every
901                // shape below — it is the element that stands for the block.
902                match info {
903                    CodeBlockKind::Fenced(info) => {
904                        let lang = info.split(' ').next().unwrap_or_default();
905                        if lang.is_empty() {
906                            self.write_pre_open()?;
907                            self.write("<code>")
908                        // MBR EXTENSION: Mermaid diagram support
909                        } else if self.config.enable_mermaid && lang == "mermaid" {
910                            self.codeblock_state = Some("</pre>".into());
911                            self.write("<pre class=\"mermaid\"")?;
912                            if self.emit_block_lines {
913                                self.write_block_line()?;
914                            }
915                            self.write(">")
916                        } else {
917                            self.write_pre_open()?;
918                            self.write("<code class=\"language-")?;
919                            escape_html(&mut self.writer, lang)?;
920                            self.write("\">")
921                        }
922                    }
923                    CodeBlockKind::Indented => {
924                        self.write_pre_open()?;
925                        self.write("<code>")
926                    }
927                }
928            }
929            Tag::List(Some(1)) => {
930                if self.end_newline {
931                    self.write("<ol>\n")
932                } else {
933                    self.write("\n<ol>\n")
934                }
935            }
936            Tag::List(Some(start)) => {
937                if self.end_newline {
938                    self.write("<ol start=\"")?;
939                } else {
940                    self.write("\n<ol start=\"")?;
941                }
942                write!(&mut self.writer, "{}", start)?;
943                self.write("\">\n")
944            }
945            Tag::List(None) => {
946                if self.end_newline {
947                    self.write("<ul>\n")
948                } else {
949                    self.write("\n<ul>\n")
950                }
951            }
952            // MBR EXTENSION: `data-mbr-line` on the `<li>`, never on the
953            // enclosing `<ul>`/`<ol>` — a list's own line always equals its
954            // first item's, so an attribute there is pure duplication.
955            Tag::Item => {
956                if self.emit_block_lines {
957                    self.write(if self.end_newline { "<li" } else { "\n<li" })?;
958                    self.write_block_line()?;
959                    self.write(">")
960                } else if self.end_newline {
961                    self.write("<li>")
962                } else {
963                    self.write("\n<li>")
964                }
965            }
966            Tag::DefinitionList => {
967                if self.end_newline {
968                    self.write("<dl>\n")
969                } else {
970                    self.write("\n<dl>\n")
971                }
972            }
973            Tag::DefinitionListTitle => {
974                // MBR EXTENSION: definition lists render as an FAQ-style
975                // disclosure list -- the `<dd>` answer is collapsed until its
976                // `<dt>` question is activated. That behaviour is pure CSS (see
977                // the "Definition Lists" section of `templates/theme.css`), and
978                // pure CSS can only observe activation as `:focus`, which a
979                // `<dt>` cannot receive unless it is made focusable. The
980                // `tabindex` is therefore load-bearing, not decoration: drop it
981                // and every answer becomes permanently unreachable.
982                //
983                // `data-mbr-line`: one of the tags in
984                // `markdown::is_review_block_start`. Flashcard reviews address
985                // a card by its term's source line, and a tight term has no
986                // `<p>` that could carry the line instead.
987                self.write(if self.end_newline { "<dt" } else { "\n<dt" })?;
988                if self.emit_block_lines {
989                    self.write_block_line()?;
990                }
991                self.write(" tabindex=\"0\">")
992            }
993            // MBR EXTENSION: `data-mbr-line`, for the same reason as `<dt>`.
994            Tag::DefinitionListDefinition => {
995                self.write(if self.end_newline { "<dd" } else { "\n<dd" })?;
996                if self.emit_block_lines {
997                    self.write_block_line()?;
998                }
999                self.write(">")
1000            }
1001            Tag::Subscript => self.write("<sub>"),
1002            Tag::Superscript => self.write("<sup>"),
1003            Tag::Emphasis => self.write("<em>"),
1004            Tag::Strong => self.write("<strong>"),
1005            Tag::Strikethrough => self.write("<del>"),
1006            Tag::Link {
1007                link_type: LinkType::Email,
1008                dest_url,
1009                title,
1010                id: _,
1011            } => {
1012                self.write("<a href=\"mailto:")?;
1013                escape_href(&mut self.writer, &dest_url)?;
1014                if !title.is_empty() {
1015                    self.write("\" title=\"")?;
1016                    escape_html(&mut self.writer, &title)?;
1017                }
1018                self.write("\">")
1019            }
1020            Tag::Link {
1021                link_type: _,
1022                dest_url,
1023                title,
1024                id: _,
1025            } => {
1026                self.write("<a href=\"")?;
1027                self.write_destination(&dest_url)?;
1028                if !title.is_empty() {
1029                    self.write("\" title=\"")?;
1030                    escape_html(&mut self.writer, &title)?;
1031                }
1032                self.write("\">")
1033            }
1034            Tag::Image {
1035                link_type: _,
1036                dest_url,
1037                title,
1038                id: _,
1039            } => {
1040                self.write("<img src=\"")?;
1041                self.write_destination(&dest_url)?;
1042                self.write("\" alt=\"")?;
1043                self.raw_text()?;
1044                if !title.is_empty() {
1045                    self.write("\" title=\"")?;
1046                    escape_html(&mut self.writer, &title)?;
1047                }
1048                // MBR EXTENSION: notes are often image-heavy; defer off-screen
1049                // requests and keep decoding off the main thread.
1050                self.write("\" loading=\"lazy\" decoding=\"async\" />")
1051            }
1052            Tag::FootnoteDefinition(name) => {
1053                if self.end_newline {
1054                    self.write("<div class=\"footnote-definition\" id=\"")?;
1055                } else {
1056                    self.write("\n<div class=\"footnote-definition\" id=\"")?;
1057                }
1058                escape_html(&mut self.writer, &name)?;
1059                self.write("\"><sup class=\"footnote-definition-label\">")?;
1060                let len = self.numbers.len() + 1;
1061                let number = *self.numbers.entry(name).or_insert(len);
1062                write!(&mut self.writer, "{}", number)?;
1063                self.write("</sup>")
1064            }
1065            Tag::MetadataBlock(_) => {
1066                self.in_non_writing_block = true;
1067                Ok(())
1068            }
1069        }
1070    }
1071
1072    fn end_tag(&mut self, tag: TagEnd) -> Result<(), W::Error> {
1073        // MBR EXTENSION: Section wrapping - track open block containers.
1074        // `saturating_sub` keeps a malformed event stream from panicking.
1075        if is_block_container_end(&tag) {
1076            self.container_depth = self.container_depth.saturating_sub(1);
1077        }
1078        match tag {
1079            TagEnd::HtmlBlock => {}
1080            TagEnd::Paragraph => {
1081                self.write("</p>\n")?;
1082            }
1083            TagEnd::Heading(level) => {
1084                self.write("</")?;
1085                write!(&mut self.writer, "{}", level)?;
1086                self.write(">\n")?;
1087            }
1088            TagEnd::Table => {
1089                self.write("</tbody></table></div>\n")?;
1090            }
1091            TagEnd::TableHead => {
1092                self.write("</tr></thead><tbody>\n")?;
1093                self.table_state = TableState::Body;
1094            }
1095            TagEnd::TableRow => {
1096                self.write("</tr>\n")?;
1097            }
1098            TagEnd::TableCell => {
1099                match self.table_state {
1100                    TableState::Head => {
1101                        self.write("</th>")?;
1102                    }
1103                    TableState::Body => {
1104                        self.write("</td>")?;
1105                    }
1106                }
1107                self.table_cell_index += 1;
1108            }
1109            TagEnd::BlockQuote(_) => {
1110                self.write("</blockquote>\n")?;
1111            }
1112            TagEnd::CodeBlock => {
1113                match self.codeblock_state.take() {
1114                    Some(closing) => self.write(closing.as_ref())?,
1115                    None => self.write("</code></pre>")?,
1116                }
1117                self.write("\n")?;
1118            }
1119            TagEnd::List(true) => {
1120                self.write("</ol>\n")?;
1121            }
1122            TagEnd::List(false) => {
1123                self.write("</ul>\n")?;
1124            }
1125            TagEnd::Item => {
1126                self.write("</li>\n")?;
1127            }
1128            TagEnd::DefinitionList => {
1129                self.write("</dl>\n")?;
1130            }
1131            TagEnd::DefinitionListTitle => {
1132                self.write("</dt>\n")?;
1133            }
1134            TagEnd::DefinitionListDefinition => {
1135                self.write("</dd>\n")?;
1136            }
1137            TagEnd::Emphasis => {
1138                self.write("</em>")?;
1139            }
1140            TagEnd::Superscript => {
1141                self.write("</sup>")?;
1142            }
1143            TagEnd::Subscript => {
1144                self.write("</sub>")?;
1145            }
1146            TagEnd::Strong => {
1147                self.write("</strong>")?;
1148            }
1149            TagEnd::Strikethrough => {
1150                self.write("</del>")?;
1151            }
1152            TagEnd::Link => {
1153                self.write("</a>")?;
1154            }
1155            TagEnd::Image => (), // shouldn't happen, handled in start
1156            TagEnd::FootnoteDefinition => {
1157                self.write("</div>\n")?;
1158            }
1159            TagEnd::MetadataBlock(_) => {
1160                self.in_non_writing_block = false;
1161            }
1162        }
1163        Ok(())
1164    }
1165
1166    // run raw text, consuming end tag
1167    fn raw_text(&mut self) -> Result<(), W::Error> {
1168        let mut nest = 0;
1169        // MBR EXTENSION: this is the second of the writer's two event loops, and
1170        // the reason `next_event` exists. Consuming an image's inner events
1171        // without counting them would shift every later `data-mbr-line` by the
1172        // size of the alt text.
1173        while let Some(event) = self.next_event() {
1174            match event {
1175                Start(_) => nest += 1,
1176                End(_) => {
1177                    if nest == 0 {
1178                        break;
1179                    }
1180                    nest -= 1;
1181                }
1182                Html(_) => {}
1183                InlineHtml(text) | Code(text) | Text(text) => {
1184                    // Don't use escape_html_body_text here.
1185                    // The output of this function is used in the `alt` attribute.
1186                    escape_html(&mut self.writer, &text)?;
1187                    self.end_newline = text.ends_with('\n');
1188                }
1189                InlineMath(text) => {
1190                    self.write("$")?;
1191                    escape_html(&mut self.writer, &text)?;
1192                    self.write("$")?;
1193                }
1194                DisplayMath(text) => {
1195                    self.write("$$")?;
1196                    escape_html(&mut self.writer, &text)?;
1197                    self.write("$$")?;
1198                }
1199                SoftBreak | HardBreak | Rule => {
1200                    self.write(" ")?;
1201                }
1202                FootnoteReference(name) => {
1203                    let len = self.numbers.len() + 1;
1204                    let number = *self.numbers.entry(name).or_insert(len);
1205                    write!(&mut self.writer, "[{}]", number)?;
1206                }
1207                TaskListMarker(true) => self.write("[x]")?,
1208                TaskListMarker(false) => self.write("[ ]")?,
1209            }
1210        }
1211        Ok(())
1212    }
1213}
1214
1215/// Iterate over an `Iterator` of `Event`s, generate HTML for each `Event`, and
1216/// push it to a `String`.
1217///
1218/// # Examples
1219///
1220/// ```
1221/// use pulldown_cmark::{html, Parser};
1222///
1223/// let markdown_str = r#"
1224/// hello
1225/// =====
1226///
1227/// * alpha
1228/// * beta
1229/// "#;
1230/// let parser = Parser::new(markdown_str);
1231///
1232/// let mut html_buf = String::new();
1233/// html::push_html(&mut html_buf, parser);
1234///
1235/// assert_eq!(html_buf, r#"<h1>hello</h1>
1236/// <ul>
1237/// <li>alpha</li>
1238/// <li>beta</li>
1239/// </ul>
1240/// "#);
1241/// ```
1242pub fn push_html<'a, I>(s: &mut String, iter: I)
1243where
1244    I: Iterator<Item = Event<'a>>,
1245{
1246    write_html_fmt(s, iter).expect("writing to a String cannot fail")
1247}
1248
1249/// Iterate over an `Iterator` of `Event`s, generate HTML for each `Event`, and
1250/// write it out to an I/O stream.
1251///
1252/// **Note**: using this function with an unbuffered writer like a file or socket
1253/// will result in poor performance. Wrap these in a
1254/// [`BufWriter`](https://doc.rust-lang.org/std/io/struct.BufWriter.html) to
1255/// prevent unnecessary slowdowns.
1256///
1257/// # Examples
1258///
1259/// ```
1260/// use pulldown_cmark::{html, Parser};
1261/// use std::io::Cursor;
1262///
1263/// let markdown_str = r#"
1264/// hello
1265/// =====
1266///
1267/// * alpha
1268/// * beta
1269/// "#;
1270/// let mut bytes = Vec::new();
1271/// let parser = Parser::new(markdown_str);
1272///
1273/// html::write_html_io(Cursor::new(&mut bytes), parser);
1274///
1275/// assert_eq!(&String::from_utf8_lossy(&bytes)[..], r#"<h1>hello</h1>
1276/// <ul>
1277/// <li>alpha</li>
1278/// <li>beta</li>
1279/// </ul>
1280/// "#);
1281/// ```
1282pub fn write_html_io<'a, I, W>(writer: W, iter: I) -> std::io::Result<()>
1283where
1284    I: Iterator<Item = Event<'a>>,
1285    W: std::io::Write,
1286{
1287    HtmlWriter::new(iter, IoWriter(writer)).run()
1288}
1289
1290/// Iterate over an `Iterator` of `Event`s, generate HTML for each `Event`, and
1291/// write it into Unicode-accepting buffer or stream.
1292///
1293/// # Examples
1294///
1295/// ```
1296/// use pulldown_cmark::{html, Parser};
1297///
1298/// let markdown_str = r#"
1299/// hello
1300/// =====
1301///
1302/// * alpha
1303/// * beta
1304/// "#;
1305/// let mut buf = String::new();
1306/// let parser = Parser::new(markdown_str);
1307///
1308/// html::write_html_fmt(&mut buf, parser);
1309///
1310/// assert_eq!(buf, r#"<h1>hello</h1>
1311/// <ul>
1312/// <li>alpha</li>
1313/// <li>beta</li>
1314/// </ul>
1315/// "#);
1316/// ```
1317pub fn write_html_fmt<'a, I, W>(writer: W, iter: I) -> core::fmt::Result
1318where
1319    I: Iterator<Item = Event<'a>>,
1320    W: core::fmt::Write,
1321{
1322    HtmlWriter::new(iter, FmtWriter(writer)).run()
1323}
1324
1325// ============================================================================
1326// MBR EXTENSION: Public API
1327// ============================================================================
1328
1329/// Push HTML with MBR extensions enabled (sections and mermaid support).
1330///
1331/// This is the primary function for MBR's markdown rendering. It wraps content
1332/// in `<section>` tags with `<hr>` as dividers, and renders mermaid code blocks
1333/// as `<pre class="mermaid">`.
1334///
1335/// # Example
1336///
1337/// ```rust,ignore
1338/// use mbr_core::html::push_html_mbr;
1339/// use pulldown_cmark::Parser;
1340///
1341/// let markdown = "First section\n\n---\n\nSecond section";
1342/// let parser = Parser::new(markdown);
1343/// let mut html = String::new();
1344/// push_html_mbr(&mut html, parser);
1345///
1346/// // Output:
1347/// // <section>
1348/// // <p>First section</p>
1349/// // </section>
1350/// // <hr />
1351/// // <section>
1352/// // <p>Second section</p>
1353/// // </section>
1354/// ```
1355pub fn push_html_mbr<'a, I>(s: &mut String, iter: I)
1356where
1357    I: Iterator<Item = Event<'a>>,
1358{
1359    write_html_fmt_with_config(s, iter, HtmlConfig::mbr_defaults())
1360        .expect("writing to a String cannot fail")
1361}
1362
1363/// Push HTML with MBR extensions and section attributes.
1364///
1365/// Like [`push_html_mbr`] but with pre-parsed section attributes. Attributes
1366/// are applied to `<section>` tags based on their index (0-based).
1367///
1368/// # Example
1369///
1370/// ```rust,ignore
1371/// use mbr_core::html::push_html_mbr_with_attrs;
1372/// use mbr_core::attrs::ParsedAttrs;
1373/// use pulldown_cmark::Parser;
1374/// use std::collections::HashMap;
1375///
1376/// let markdown = "First section\n\n---\n\nSecond section";
1377/// let parser = Parser::new(markdown);
1378///
1379/// let mut section_attrs = HashMap::new();
1380/// section_attrs.insert(1, ParsedAttrs::parse("{#intro .highlight}").unwrap());
1381///
1382/// let mut html = String::new();
1383/// push_html_mbr_with_attrs(&mut html, parser, section_attrs);
1384///
1385/// // Output includes: <section id="intro" class="highlight">
1386/// ```
1387pub fn push_html_mbr_with_attrs<'a, I>(
1388    s: &mut String,
1389    iter: I,
1390    section_attrs: HashMap<usize, ParsedAttrs>,
1391) where
1392    I: Iterator<Item = Event<'a>>,
1393{
1394    write_html_fmt_with_config(s, iter, HtmlConfig::mbr_with_section_attrs(section_attrs))
1395        .expect("writing to a String cannot fail")
1396}
1397
1398/// Push HTML with explicit configuration.
1399///
1400/// Allows fine-grained control over which MBR extensions are enabled.
1401///
1402/// # Example
1403///
1404/// ```rust,ignore
1405/// use mbr_core::html::{push_html_with_config, HtmlConfig};
1406/// use pulldown_cmark::Parser;
1407///
1408/// let config = HtmlConfig {
1409///     enable_sections: true,
1410///     enable_mermaid: false,
1411/// };
1412///
1413/// let markdown = "Hello world";
1414/// let parser = Parser::new(markdown);
1415/// let mut html = String::new();
1416/// push_html_with_config(&mut html, parser, config);
1417/// ```
1418pub fn push_html_with_config<'a, I>(s: &mut String, iter: I, config: HtmlConfig)
1419where
1420    I: Iterator<Item = Event<'a>>,
1421{
1422    write_html_fmt_with_config(s, iter, config).expect("writing to a String cannot fail")
1423}
1424
1425/// Internal: write HTML with explicit configuration.
1426fn write_html_fmt_with_config<'a, I, W>(writer: W, iter: I, config: HtmlConfig) -> core::fmt::Result
1427where
1428    I: Iterator<Item = Event<'a>>,
1429    W: core::fmt::Write,
1430{
1431    HtmlWriter::new_with_config(iter, FmtWriter(writer), config).run()
1432}
1433
1434#[cfg(test)]
1435mod tests {
1436    use super::*;
1437    use pulldown_cmark::{Options, Parser};
1438
1439    /// Helper to render markdown with custom config
1440    fn render_with_config(markdown: &str, config: HtmlConfig) -> String {
1441        let parser = Parser::new(markdown);
1442        let mut html = String::new();
1443        push_html_with_config(&mut html, parser, config);
1444        html
1445    }
1446
1447    // ---- task markup ---------------------------------------------------------
1448
1449    #[test]
1450    fn task_checkbox_carries_its_line_when_one_is_known() {
1451        assert_eq!(
1452            task_checkbox_html(TaskStatus::Open, Some(42)),
1453            concat!(
1454                r#"<input type="checkbox" class="mbr-task-check" id="mbr-task-42" "#,
1455                r#"data-mbr-task-line="42" data-mbr-task-status="open" disabled>"#
1456            )
1457        );
1458        assert_eq!(
1459            task_checkbox_html(TaskStatus::Done, Some(1)),
1460            concat!(
1461                r#"<input type="checkbox" class="mbr-task-check" id="mbr-task-1" "#,
1462                r#"data-mbr-task-line="1" data-mbr-task-status="done" checked disabled>"#
1463            )
1464        );
1465    }
1466
1467    #[test]
1468    fn task_checkbox_without_a_line_omits_the_anchor() {
1469        let html = task_checkbox_html(TaskStatus::Canceled, None);
1470        assert_eq!(
1471            html,
1472            r#"<input type="checkbox" class="mbr-task-check" data-mbr-task-status="canceled" disabled>"#
1473        );
1474        assert!(!html.contains("id="));
1475    }
1476
1477    #[test]
1478    fn every_task_checkbox_is_inert() {
1479        for status in [TaskStatus::Open, TaskStatus::Done, TaskStatus::Canceled] {
1480            for line in [None, Some(7)] {
1481                assert!(
1482                    task_checkbox_html(status, line).ends_with(" disabled>"),
1483                    "{status:?} {line:?}"
1484                );
1485            }
1486        }
1487    }
1488
1489    #[test]
1490    fn a_task_with_no_annotations_renders_no_chips() {
1491        assert_eq!(task_annotations_html(&Annotations::default()), "");
1492    }
1493
1494    #[test]
1495    fn annotation_chips_render_in_a_fixed_order() {
1496        let annotations = Annotations {
1497            priority: TaskPriority::Urgent,
1498            tags: vec!["work".to_string(), "ops".to_string()],
1499            due: chrono::NaiveDate::from_ymd_opt(2026, 8, 5).and_then(|d| d.and_hms_opt(15, 0, 0)),
1500            due_has_time: true,
1501            done: chrono::NaiveDate::from_ymd_opt(2026, 8, 4).and_then(|d| d.and_hms_opt(0, 0, 0)),
1502            done_has_time: false,
1503            moved_to: chrono::NaiveDate::from_ymd_opt(2026, 9, 1),
1504        };
1505        assert_eq!(
1506            task_annotations_html(&annotations),
1507            concat!(
1508                r#" <span class="mbr-task-pri mbr-task-pri-urgent" role="img" "#,
1509                r#"aria-label="Urgent priority" title="Urgent priority"></span>"#,
1510                r#" <span class="mbr-task-tag">#work</span>"#,
1511                r#" <span class="mbr-task-tag">#ops</span>"#,
1512                r#" <time class="mbr-task-due" datetime="2026-08-05T15:00">Aug 5, 3:00 PM</time>"#,
1513                r#" <time class="mbr-task-completed" datetime="2026-08-04">Aug 4</time>"#,
1514                r#" <time class="mbr-task-moved" datetime="2026-09-01">Sep 1</time>"#
1515            )
1516        );
1517    }
1518
1519    #[test]
1520    fn a_high_priority_dot_is_labelled_differently_from_an_urgent_one() {
1521        let high = task_annotations_html(&Annotations {
1522            priority: TaskPriority::High,
1523            ..Annotations::default()
1524        });
1525        assert!(high.contains("mbr-task-pri-high"), "{high}");
1526        assert!(high.contains(r#"aria-label="High priority""#), "{high}");
1527        assert!(!high.contains("mbr-task-pri-urgent"), "{high}");
1528    }
1529
1530    /// The tag grammar cannot produce these today; the escape is there so a
1531    /// later widening of it does not silently become an injection.
1532    #[test]
1533    fn tag_chips_are_html_escaped() {
1534        let html = task_annotations_html(&Annotations {
1535            tags: vec!["<script>".to_string()],
1536            ..Annotations::default()
1537        });
1538        assert!(html.contains("&lt;script&gt;"), "{html}");
1539        assert!(!html.contains("<script>"), "{html}");
1540    }
1541
1542    #[test]
1543    fn only_a_canceled_task_gets_the_canceled_text_class() {
1544        assert!(task_text_open(TaskStatus::Canceled).contains("mbr-task-canceled"));
1545        for status in [TaskStatus::Open, TaskStatus::Done] {
1546            assert_eq!(task_text_open(status), r#"<span class="mbr-task-text">"#);
1547        }
1548    }
1549
1550    /// Renders with the same options the application uses (`Options::all()`),
1551    /// which is what enables tables, footnotes, task lists and math.
1552    fn render_mbr(markdown: &str) -> String {
1553        let parser = Parser::new_ext(markdown, Options::all());
1554        let mut html = String::new();
1555        push_html_mbr(&mut html, parser);
1556        html
1557    }
1558
1559    /// Returns the slice strictly between the first `open` tag and the first
1560    /// `close` tag that follows it.
1561    ///
1562    /// Substring *counts* of `<section>`/`</section>` stay balanced even when the
1563    /// tags are mis-nested, so container nesting has to be inspected directly.
1564    fn between<'a>(html: &'a str, open: &str, close: &str) -> &'a str {
1565        let start = html
1566            .find(open)
1567            .unwrap_or_else(|| panic!("expected {open} in output:\n{html}"))
1568            + open.len();
1569        let end = html[start..]
1570            .find(close)
1571            .unwrap_or_else(|| panic!("expected {close} after {open} in output:\n{html}"))
1572            + start;
1573        &html[start..end]
1574    }
1575
1576    #[test]
1577    fn test_sections_disabled() {
1578        // With enable_sections: false, no section wrappers should be added
1579        let config = HtmlConfig {
1580            enable_sections: false,
1581            enable_mermaid: false,
1582            section_attrs: HashMap::new(),
1583            block_lines: Vec::new(),
1584        };
1585        let html = render_with_config("Hello\n\n---\n\nWorld", config);
1586
1587        // Should NOT have section tags
1588        assert!(
1589            !html.contains("<section"),
1590            "Sections disabled should not produce section tags. Got: {}",
1591            html
1592        );
1593        // Should still have hr
1594        assert!(
1595            html.contains("<hr />"),
1596            "Should still have hr divider. Got: {}",
1597            html
1598        );
1599    }
1600
1601    #[test]
1602    fn test_sections_enabled_default() {
1603        // With mbr_defaults, sections should be enabled
1604        let config = HtmlConfig::mbr_defaults();
1605        let html = render_with_config("Hello\n\n---\n\nWorld", config);
1606
1607        // Should have section tags
1608        assert!(
1609            html.contains("<section>"),
1610            "Sections enabled should produce section tags. Got: {}",
1611            html
1612        );
1613    }
1614
1615    #[test]
1616    fn test_sections_with_attrs() {
1617        // Section attrs should be applied to the corresponding section
1618        let mut section_attrs = HashMap::new();
1619        section_attrs.insert(
1620            1,
1621            ParsedAttrs {
1622                id: Some("second".to_string()),
1623                classes: vec!["highlight".to_string()],
1624                attrs: vec![],
1625            },
1626        );
1627        let config = HtmlConfig::mbr_with_section_attrs(section_attrs);
1628        let html = render_with_config("First\n\n---\n\nSecond", config);
1629
1630        // Second section should have the attrs
1631        assert!(
1632            html.contains(r#"id="second""#),
1633            "Section should have id. Got: {}",
1634            html
1635        );
1636        assert!(
1637            html.contains(r#"class="highlight""#),
1638            "Section should have class. Got: {}",
1639            html
1640        );
1641    }
1642
1643    #[test]
1644    fn test_mermaid_disabled() {
1645        // With enable_mermaid: false, mermaid blocks render as normal code
1646        let config = HtmlConfig {
1647            enable_sections: false,
1648            enable_mermaid: false,
1649            section_attrs: HashMap::new(),
1650            block_lines: Vec::new(),
1651        };
1652        let html = render_with_config("```mermaid\ngraph TD\n```", config);
1653
1654        // Should have standard code block structure
1655        assert!(
1656            html.contains("<pre><code"),
1657            "Mermaid disabled should use standard code. Got: {}",
1658            html
1659        );
1660    }
1661
1662    #[test]
1663    fn test_mermaid_enabled() {
1664        // With enable_mermaid: true, mermaid blocks render as <pre class="mermaid">
1665        let config = HtmlConfig {
1666            enable_sections: false,
1667            enable_mermaid: true,
1668            section_attrs: HashMap::new(),
1669            block_lines: Vec::new(),
1670        };
1671        let html = render_with_config("```mermaid\ngraph TD\n```", config);
1672
1673        // Should have mermaid-specific structure
1674        assert!(
1675            html.contains(r#"<pre class="mermaid">"#),
1676            "Mermaid enabled should use mermaid class. Got: {}",
1677            html
1678        );
1679        // Should NOT have <code> wrapper
1680        assert!(
1681            !html.contains("<code"),
1682            "Mermaid should not have code wrapper. Got: {}",
1683            html
1684        );
1685    }
1686
1687    // ------------------------------------------------------------------
1688    // Section wrapping must not tear open block containers
1689    // ------------------------------------------------------------------
1690
1691    #[test]
1692    fn test_rule_inside_blockquote_does_not_close_section() {
1693        let html = render_mbr("> before\n>\n> ---\n>\n> after");
1694        let inside = between(&html, "<blockquote>", "</blockquote>");
1695
1696        assert!(
1697            !inside.contains("</section>"),
1698            "A rule inside a blockquote must not close the surrounding section \
1699             (the browser would hoist everything after it out of the quote). Got: {html}"
1700        );
1701        assert!(
1702            !inside.contains("<section"),
1703            "A rule inside a blockquote must not open a section. Got: {html}"
1704        );
1705        assert!(
1706            inside.contains("<hr />"),
1707            "The rule should still render as a plain <hr />. Got: {html}"
1708        );
1709        assert!(
1710            inside.contains("after"),
1711            "Content after the rule must stay inside the blockquote. Got: {html}"
1712        );
1713    }
1714
1715    #[test]
1716    fn test_rule_inside_list_item_does_not_close_section() {
1717        let html = render_mbr("- a\n\n  ---\n\n- b");
1718        let inside = between(&html, "<li>", "</li>");
1719
1720        assert!(
1721            !inside.contains("</section>"),
1722            "A rule inside a list item must not close the surrounding section. Got: {html}"
1723        );
1724        assert!(
1725            !inside.contains("<section"),
1726            "A rule inside a list item must not open a section. Got: {html}"
1727        );
1728        assert!(
1729            inside.contains("<hr />"),
1730            "The rule should still render as a plain <hr />. Got: {html}"
1731        );
1732        // The <ul> must also survive intact.
1733        let list = between(&html, "<ul>", "</ul>");
1734        assert!(
1735            !list.contains("section"),
1736            "The list must not be split by a section boundary. Got: {html}"
1737        );
1738    }
1739
1740    #[test]
1741    fn test_rule_inside_definition_list_does_not_close_section() {
1742        let html = render_mbr("Term\n\n: def\n\n  ---\n\n  more\n");
1743        let inside = between(&html, "<dd>", "</dd>");
1744
1745        assert!(
1746            !inside.contains("</section>"),
1747            "A rule inside a definition must not close the surrounding section. Got: {html}"
1748        );
1749        assert!(
1750            inside.contains("more"),
1751            "Content after the rule must stay inside the definition. Got: {html}"
1752        );
1753    }
1754
1755    /// `templates/theme.css` renders definition lists as an FAQ-style
1756    /// disclosure list keyed off `dt:focus`, and only a focusable element can
1757    /// match `:focus`. Without `tabindex` on every `<dt>` there is no pure-CSS
1758    /// way to open a `<dd>`, so the answers would be permanently hidden.
1759    #[test]
1760    fn test_definition_list_titles_are_focusable() {
1761        let html = render_mbr("Tight\n: answer\n\nLoose\n\n: first para\n\n  second para\n");
1762
1763        assert!(
1764            html.contains("<dl>"),
1765            "definition lists must still open a <dl>. Got: {html}"
1766        );
1767        assert!(
1768            html.contains("<dd>"),
1769            "definitions must still render as <dd>. Got: {html}"
1770        );
1771        assert_eq!(
1772            html.matches("<dt tabindex=\"0\">").count(),
1773            2,
1774            "every <dt> must be focusable so the CSS disclosure can open its \
1775             <dd>. Got: {html}"
1776        );
1777        assert!(
1778            !html.contains("<dt>"),
1779            "no <dt> may be emitted without tabindex -- its answer would be \
1780             unreachable. Got: {html}"
1781        );
1782    }
1783
1784    /// The two `<dd>` body shapes the disclosure CSS has to collapse. A tight
1785    /// definition holds bare inline text; a loose one gets its body wrapped in
1786    /// `<p>`, whose margins would escape a zero-height box if the CSS did not
1787    /// establish a block formatting context. Pinned here so a parser upgrade
1788    /// that changes either shape shows up as a test failure rather than as a
1789    /// silently broken collapse.
1790    #[test]
1791    fn test_definition_bodies_are_tight_or_paragraph_wrapped() {
1792        let tight = render_mbr("Term\n: answer\n");
1793        assert!(
1794            tight.contains("<dd>answer</dd>"),
1795            "a tight definition should hold bare inline text. Got: {tight}"
1796        );
1797
1798        let loose = render_mbr("Term\n\n: answer\n");
1799        assert!(
1800            loose.contains("<dd>\n<p>answer</p>\n</dd>"),
1801            "a loose definition should wrap its body in <p>. Got: {loose}"
1802        );
1803    }
1804
1805    /// One question can own several answers. The CSS opens them with `dt:focus
1806    /// ~ dd` rather than `+ dd` precisely because of this shape -- with the
1807    /// adjacent combinator the second `<dd>` would stay `visibility: hidden`,
1808    /// which also makes it untabbable, so its content would be unreachable.
1809    #[test]
1810    fn test_one_title_can_own_several_definitions() {
1811        let html = render_mbr("Term\n: first\n: second\n");
1812
1813        assert_eq!(
1814            html.matches("<dt tabindex=\"0\">").count(),
1815            1,
1816            "expected a single term. Got: {html}"
1817        );
1818        assert_eq!(
1819            html.matches("<dd>").count(),
1820            2,
1821            "expected the term to own two sibling <dd>s. Got: {html}"
1822        );
1823    }
1824
1825    #[test]
1826    fn test_top_level_rule_still_splits_sections() {
1827        let html = render_mbr("Hello\n\n---\n\nWorld");
1828
1829        assert!(
1830            html.contains("</section>\n<hr />\n<section>"),
1831            "A top-level rule must still split sections. Got: {html}"
1832        );
1833    }
1834
1835    #[test]
1836    fn test_nested_rule_does_not_shift_section_attr_indices() {
1837        // markdown.rs numbers sections by counting every Event::Rule, nested or
1838        // not, so html.rs must advance its index for the nested rule too or the
1839        // attrs land on the wrong section.
1840        let mut section_attrs = HashMap::new();
1841        section_attrs.insert(
1842            2,
1843            ParsedAttrs {
1844                id: Some("third".to_string()),
1845                classes: vec![],
1846                attrs: vec![],
1847            },
1848        );
1849        let config = HtmlConfig::mbr_with_section_attrs(section_attrs);
1850        let parser = Parser::new_ext("> ---\n\n---\n\ntail", Options::all());
1851        let mut html = String::new();
1852        push_html_with_config(&mut html, parser, config);
1853
1854        assert!(
1855            html.contains(r#"<section id="third">"#),
1856            "Section index must count the nested rule. Got: {html}"
1857        );
1858    }
1859
1860    // ------------------------------------------------------------------
1861    // Script-capable destinations are neutralized
1862    // ------------------------------------------------------------------
1863
1864    #[test]
1865    fn test_javascript_link_destination_is_neutralized() {
1866        let html = render_mbr("[click me](javascript:alert(1))");
1867
1868        assert!(
1869            !html.contains("javascript:"),
1870            "javascript: href must not survive. Got: {html}"
1871        );
1872        assert!(
1873            html.contains(r#"<a href="about:blank#blocked">"#),
1874            "Blocked link should get an inert href. Got: {html}"
1875        );
1876        assert!(
1877            html.contains("click me"),
1878            "Link text must be preserved. Got: {html}"
1879        );
1880    }
1881
1882    #[test]
1883    fn test_javascript_scheme_obfuscation_is_neutralized() {
1884        // Browsers strip leading control characters/spaces and every embedded
1885        // tab/newline/CR before resolving the URL, so these all still navigate.
1886        for dest in [
1887            "JaVaScRiPt:alert(1)",
1888            "  javascript:alert(1)",
1889            "java\tscript:alert(1)",
1890            "\u{1}javascript:alert(1)",
1891        ] {
1892            let html = render_mbr(&format!("[x](<{dest}>)"));
1893            assert!(
1894                html.contains(r#"href="about:blank#blocked""#),
1895                "{dest:?} should have been neutralized. Got: {html}"
1896            );
1897        }
1898    }
1899
1900    #[test]
1901    fn test_vbscript_link_destination_is_neutralized() {
1902        let html = render_mbr("[x](vbscript:msgbox(1))");
1903        assert!(
1904            html.contains(r#"href="about:blank#blocked""#),
1905            "vbscript: href must be neutralized. Got: {html}"
1906        );
1907    }
1908
1909    #[test]
1910    fn test_non_image_data_url_is_neutralized() {
1911        let html = render_mbr("[x](data:text/html;base64,PHNjcmlwdD4=)");
1912        assert!(
1913            html.contains(r#"href="about:blank#blocked""#),
1914            "data:text/html href must be neutralized. Got: {html}"
1915        );
1916    }
1917
1918    #[test]
1919    fn test_inline_image_data_url_is_preserved() {
1920        // link_transform.rs deliberately passes data: through for inline images,
1921        // so data:image/* must keep working in both links and images.
1922        let html = render_mbr("![alt](data:image/png;base64,iVBORw0KGgo=)");
1923        assert!(
1924            html.contains(r#"src="data:image/png;base64,iVBORw0KGgo=""#),
1925            "data:image/* src must be preserved. Got: {html}"
1926        );
1927
1928        let html = render_mbr("[x](data:image/png;base64,iVBORw0KGgo=)");
1929        assert!(
1930            html.contains(r#"href="data:image/png;base64,iVBORw0KGgo=""#),
1931            "data:image/* href must be preserved. Got: {html}"
1932        );
1933    }
1934
1935    #[test]
1936    fn test_javascript_image_destination_is_neutralized() {
1937        let html = render_mbr("![alt](javascript:alert(1))");
1938        assert!(
1939            !html.contains("javascript:"),
1940            "javascript: src must not survive. Got: {html}"
1941        );
1942        assert!(
1943            html.contains(r#"<img src="about:blank#blocked""#),
1944            "Blocked image should get an inert src. Got: {html}"
1945        );
1946    }
1947
1948    #[test]
1949    fn test_ordinary_destinations_are_untouched() {
1950        let cases = [
1951            (
1952                "[a](https://example.com/p?q=1)",
1953                "https://example.com/p?q=1",
1954            ),
1955            ("[a](http://example.com/)", "http://example.com/"),
1956            ("[a](../notes/other/)", "../notes/other/"),
1957            ("[a](/images/pic.png)", "/images/pic.png"),
1958            ("[a](#anchor)", "#anchor"),
1959            ("[a](mailto:me@example.com)", "mailto:me@example.com"),
1960            ("[a](tel:+15551234)", "tel:+15551234"),
1961            // A relative file that merely looks like a scheme name.
1962            ("[a](javascript)", "javascript"),
1963        ];
1964        for (markdown, expected_href) in cases {
1965            let html = render_mbr(markdown);
1966            assert!(
1967                html.contains(&format!(r#"href="{expected_href}""#)),
1968                "{markdown} should render href={expected_href:?}. Got: {html}"
1969            );
1970        }
1971    }
1972
1973    #[test]
1974    fn test_autolink_email_is_untouched() {
1975        let html = render_mbr("<me@example.com>");
1976        assert!(
1977            html.contains(r#"href="mailto:me@example.com""#),
1978            "Email autolinks must keep working. Got: {html}"
1979        );
1980    }
1981
1982    // ------------------------------------------------------------------
1983    // Image loading hints
1984    // ------------------------------------------------------------------
1985
1986    #[test]
1987    fn test_images_get_lazy_loading_and_async_decoding() {
1988        let html = render_mbr("![alt text](/images/pic.png)");
1989
1990        assert!(
1991            html.contains(r#"loading="lazy""#),
1992            "Images should be lazily loaded. Got: {html}"
1993        );
1994        assert!(
1995            html.contains(r#"decoding="async""#),
1996            "Images should decode asynchronously. Got: {html}"
1997        );
1998    }
1999
2000    #[test]
2001    fn test_image_with_title_keeps_loading_hints() {
2002        let html = render_mbr(r#"![alt](/images/pic.png "A title")"#);
2003
2004        assert!(
2005            html.contains(r#"title="A title" loading="lazy" decoding="async" />"#),
2006            "Title and loading hints should both be emitted. Got: {html}"
2007        );
2008    }
2009
2010    // ------------------------------------------------------------------
2011    // Scheme normalization unit coverage
2012    // ------------------------------------------------------------------
2013
2014    #[test]
2015    fn test_is_blocked_destination() {
2016        for dest in [
2017            "javascript:alert(1)",
2018            "JAVASCRIPT:alert(1)",
2019            " \u{c}javascript:alert(1)",
2020            "v\rbscript:x", // still `vbscript:` once CRs are removed
2021            "vbscript:x",
2022            "data:text/html,<script>",
2023            "data:,plain",
2024            "data:image",     // no slash: not `image/`
2025            "data:imag",      // truncated payload
2026            "javascript:",    // empty body still navigates
2027            "jAvAsCrIpT\n:x", // newline before the colon
2028        ] {
2029            assert!(is_blocked_destination(dest), "{dest:?} should be blocked");
2030        }
2031
2032        for dest in [
2033            "",
2034            ":",
2035            "https://example.com",
2036            "HTTPS://example.com",
2037            "mailto:me@example.com",
2038            "../a/b",
2039            "/a/b",
2040            "#frag",
2041            "?q=1",
2042            "javascript",            // no colon: a relative path
2043            "a javascript:alert(1)", // space is not stripped mid-URL
2044            "not-javascript:x",
2045            "data:image/png;base64,AAAA",
2046            "DATA:IMAGE/PNG;base64,AAAA",
2047            "data:image/svg+xml,%3Csvg%3E",
2048            "blob:https://example.com/uuid",
2049            "verylongschemename:x",
2050        ] {
2051            assert!(
2052                !is_blocked_destination(dest),
2053                "{dest:?} should not be blocked"
2054            );
2055        }
2056    }
2057
2058    // ---- data-mbr-line (review anchors) --------------------------------------
2059
2060    /// Renders `events` with a `block_lines` table built from `(at, line)` pairs.
2061    fn render_with_block_lines(events: Vec<Event<'static>>, pairs: &[(u32, u32)]) -> String {
2062        let block_lines = pairs
2063            .iter()
2064            .map(|&(at, line)| BlockLine { at, line })
2065            .collect();
2066        let config = HtmlConfig {
2067            enable_sections: false,
2068            enable_mermaid: true,
2069            section_attrs: HashMap::new(),
2070            block_lines,
2071        };
2072        let mut html = String::new();
2073        push_html_with_config(&mut html, events.into_iter(), config);
2074        html
2075    }
2076
2077    /// Parses `markdown` and renders it with a table built by finding the
2078    /// indices of the events matching `pick`.
2079    fn render_markdown_with_block_lines(
2080        markdown: &str,
2081        pick: impl Fn(&Event<'_>) -> bool,
2082    ) -> String {
2083        let events: Vec<Event<'static>> = Parser::new_ext(markdown, Options::all())
2084            .map(|e| owned_event(&e))
2085            .collect();
2086        let pairs: Vec<(u32, u32)> = events
2087            .iter()
2088            .enumerate()
2089            .filter(|(_, e)| pick(e))
2090            .map(|(i, _)| (i as u32, (i as u32) + 1))
2091            .collect();
2092        render_with_block_lines(events, &pairs)
2093    }
2094
2095    /// A `'static` clone of `event`, so test tables can outlive the parser.
2096    fn owned_event(event: &Event<'_>) -> Event<'static> {
2097        // Round-tripping through the writer is not needed; only the few shapes
2098        // these tests use have to survive, and `CowStr::from(String)` is owned.
2099        match event {
2100            Start(tag) => Start(owned_tag(tag)),
2101            End(tag) => End(*tag),
2102            Text(t) => Text(CowStr::from(t.to_string())),
2103            Code(t) => Code(CowStr::from(t.to_string())),
2104            Html(t) => Html(CowStr::from(t.to_string())),
2105            InlineHtml(t) => InlineHtml(CowStr::from(t.to_string())),
2106            InlineMath(t) => InlineMath(CowStr::from(t.to_string())),
2107            DisplayMath(t) => DisplayMath(CowStr::from(t.to_string())),
2108            FootnoteReference(t) => FootnoteReference(CowStr::from(t.to_string())),
2109            SoftBreak => SoftBreak,
2110            HardBreak => HardBreak,
2111            Rule => Rule,
2112            TaskListMarker(b) => TaskListMarker(*b),
2113        }
2114    }
2115
2116    fn owned_tag(tag: &Tag<'_>) -> Tag<'static> {
2117        match tag {
2118            Tag::Paragraph => Tag::Paragraph,
2119            Tag::Heading {
2120                level,
2121                id,
2122                classes,
2123                attrs,
2124            } => Tag::Heading {
2125                level: *level,
2126                id: id.as_ref().map(|i| CowStr::from(i.to_string())),
2127                classes: classes
2128                    .iter()
2129                    .map(|c| CowStr::from(c.to_string()))
2130                    .collect(),
2131                attrs: attrs
2132                    .iter()
2133                    .map(|(k, v)| {
2134                        (
2135                            CowStr::from(k.to_string()),
2136                            v.as_ref().map(|v| CowStr::from(v.to_string())),
2137                        )
2138                    })
2139                    .collect(),
2140            },
2141            Tag::BlockQuote(kind) => Tag::BlockQuote(*kind),
2142            Tag::CodeBlock(CodeBlockKind::Fenced(info)) => {
2143                Tag::CodeBlock(CodeBlockKind::Fenced(CowStr::from(info.to_string())))
2144            }
2145            Tag::CodeBlock(CodeBlockKind::Indented) => Tag::CodeBlock(CodeBlockKind::Indented),
2146            Tag::List(start) => Tag::List(*start),
2147            Tag::Item => Tag::Item,
2148            Tag::Table(a) => Tag::Table(a.clone()),
2149            Tag::TableHead => Tag::TableHead,
2150            Tag::TableRow => Tag::TableRow,
2151            Tag::TableCell => Tag::TableCell,
2152            Tag::Emphasis => Tag::Emphasis,
2153            Tag::Strong => Tag::Strong,
2154            Tag::Strikethrough => Tag::Strikethrough,
2155            Tag::Image {
2156                link_type,
2157                dest_url,
2158                title,
2159                id,
2160            } => Tag::Image {
2161                link_type: *link_type,
2162                dest_url: CowStr::from(dest_url.to_string()),
2163                title: CowStr::from(title.to_string()),
2164                id: CowStr::from(id.to_string()),
2165            },
2166            Tag::Link {
2167                link_type,
2168                dest_url,
2169                title,
2170                id,
2171            } => Tag::Link {
2172                link_type: *link_type,
2173                dest_url: CowStr::from(dest_url.to_string()),
2174                title: CowStr::from(title.to_string()),
2175                id: CowStr::from(id.to_string()),
2176            },
2177            Tag::HtmlBlock => Tag::HtmlBlock,
2178            Tag::DefinitionList => Tag::DefinitionList,
2179            Tag::DefinitionListTitle => Tag::DefinitionListTitle,
2180            Tag::DefinitionListDefinition => Tag::DefinitionListDefinition,
2181            other => panic!("unhandled tag in test helper: {other:?}"),
2182        }
2183    }
2184
2185    /// Each of the six tags carries the attribute, from a hand-built table.
2186    #[test]
2187    fn block_line_attribute_on_each_tag() {
2188        let events: Vec<Event<'static>> = vec![
2189            Start(Tag::Paragraph),       // 0
2190            Text(CowStr::Borrowed("p")), // 1
2191            End(TagEnd::Paragraph),      // 2
2192            Start(Tag::Heading {
2193                // 3
2194                level: pulldown_cmark::HeadingLevel::H2,
2195                id: None,
2196                classes: vec![],
2197                attrs: vec![],
2198            }),
2199            Text(CowStr::Borrowed("h")),                            // 4
2200            End(TagEnd::Heading(pulldown_cmark::HeadingLevel::H2)), // 5
2201            Start(Tag::List(None)),                                 // 6
2202            Start(Tag::Item),                                       // 7
2203            Text(CowStr::Borrowed("li")),                           // 8
2204            End(TagEnd::Item),                                      // 9
2205            End(TagEnd::List(false)),                               // 10
2206            Start(Tag::BlockQuote(None)),                           // 11
2207            End(TagEnd::BlockQuote(None)),                          // 12
2208            Start(Tag::CodeBlock(CodeBlockKind::Fenced(CowStr::Borrowed("")))), // 13
2209            End(TagEnd::CodeBlock),                                 // 14
2210            Start(Tag::Table(vec![Alignment::None])),               // 15
2211            Start(Tag::TableHead),                                  // 16
2212            Start(Tag::TableCell),                                  // 17
2213            End(TagEnd::TableCell),                                 // 18
2214            End(TagEnd::TableHead),                                 // 19
2215            End(TagEnd::Table),                                     // 20
2216        ];
2217        // A distinct line per block, so a mix-up cannot pass.
2218        let html = render_with_block_lines(
2219            events,
2220            &[
2221                (0, 11),  // <p>
2222                (3, 22),  // <h2>
2223                (6, 33),  // Tag::List — recorded here on purpose; see below
2224                (7, 44),  // <li>
2225                (11, 55), // <blockquote>
2226                (13, 66), // <pre>
2227                (15, 77), // <table>
2228                (17, 88), // Tag::TableCell — likewise
2229            ],
2230        );
2231
2232        assert!(html.contains(r#"<p data-mbr-line="11">"#), "{html}");
2233        assert!(html.contains(r#"<h2 data-mbr-line="22">"#), "{html}");
2234        assert!(html.contains(r#"<li data-mbr-line="44">"#), "{html}");
2235        assert!(
2236            html.contains(r#"<blockquote data-mbr-line="55">"#),
2237            "{html}"
2238        );
2239        assert!(html.contains(r#"<pre data-mbr-line="66"><code>"#), "{html}");
2240        assert!(
2241            html.contains(r#"<div class="mbr-table-scroll"><table data-mbr-line="77">"#),
2242            "the line stays on the <table>, inside its scroll wrapper: {html}"
2243        );
2244
2245        // The excluded tags stay bare even when the table names them, which is
2246        // what keeps `markdown::is_review_block_start` the single definition of
2247        // the set: an entry it never records cannot leak through the writer.
2248        assert!(html.contains("<ul>"), "no attribute on <ul>: {html}");
2249        assert!(html.contains("<th>"), "no attribute on <th>: {html}");
2250        assert!(!html.contains(r#""33""#), "{html}");
2251        assert!(!html.contains(r#""88""#), "{html}");
2252    }
2253
2254    /// GOTCHA: `raw_text` is the writer's *second* event loop. If it consumed
2255    /// events without counting them, every `data-mbr-line` after the first image
2256    /// on the page would name the wrong line. Route `raw_text` back through
2257    /// `self.iter.next()` instead of `next_event` and this test fails.
2258    #[test]
2259    fn block_line_index_survives_image_alt_text() {
2260        // The alt text carries inline markup, so `raw_text` eats several events
2261        // (`Start(Emphasis)`, `Text`, `End(Emphasis)`, …) before the image's
2262        // `End`. Every one of them has to advance the counter.
2263        let markdown =
2264            "![alt *with* `markup` and **more**](img.png)\n\nSecond paragraph.\n\nThird one.\n";
2265        let events: Vec<Event<'static>> = Parser::new_ext(markdown, Options::all())
2266            .map(|e| owned_event(&e))
2267            .collect();
2268
2269        // Find the two later paragraph starts and give them recognisable lines.
2270        let paragraph_starts: Vec<usize> = events
2271            .iter()
2272            .enumerate()
2273            .filter(|(_, e)| matches!(e, Start(Tag::Paragraph)))
2274            .map(|(i, _)| i)
2275            .collect();
2276        assert_eq!(
2277            paragraph_starts.len(),
2278            3,
2279            "the image paragraph plus two more: {events:?}"
2280        );
2281        let pairs = [
2282            (paragraph_starts[0] as u32, 1),
2283            (paragraph_starts[1] as u32, 3),
2284            (paragraph_starts[2] as u32, 5),
2285        ];
2286
2287        let html = render_with_block_lines(events, &pairs);
2288        assert!(
2289            html.contains(r#"<p data-mbr-line="3">Second paragraph.</p>"#),
2290            "the paragraph after the image must keep its own line: {html}"
2291        );
2292        assert!(
2293            html.contains(r#"<p data-mbr-line="5">Third one.</p>"#),
2294            "and so must the one after that: {html}"
2295        );
2296    }
2297
2298    /// `<dt>`/`<dd>` take the attribute too, ahead of the `tabindex` the FAQ
2299    /// disclosure depends on — and with the table empty they are byte-for-byte
2300    /// what they were before.
2301    #[test]
2302    fn block_line_attribute_on_definition_list_tags() {
2303        let markdown = "Term\n: answer\n";
2304        let html = render_markdown_with_block_lines(markdown, |e| {
2305            matches!(
2306                e,
2307                Start(Tag::DefinitionListTitle | Tag::DefinitionListDefinition)
2308            )
2309        });
2310        assert!(
2311            html.contains(r#"<dt data-mbr-line="2" tabindex="0">Term</dt>"#),
2312            "{html}"
2313        );
2314        assert!(
2315            html.contains(r#"<dd data-mbr-line="5">answer</dd>"#),
2316            "{html}"
2317        );
2318
2319        let plain = render_markdown_with_block_lines(markdown, |_| false);
2320        assert!(
2321            plain.contains("<dt tabindex=\"0\">Term</dt>\n<dd>answer</dd>"),
2322            "{plain}"
2323        );
2324    }
2325
2326    /// The writer numbers exactly the events the table names, which is what
2327    /// lets `ReviewLines::TermsOnly` (`--no-review` with editing on) be decided
2328    /// entirely in `markdown.rs`: a table holding only `<dt>` starts leaves
2329    /// every other element — the paragraph, the item, the `<dd>` — bare, even
2330    /// though `emit_block_lines` is on for the whole document.
2331    #[test]
2332    fn block_lines_holding_only_terms_number_only_terms() {
2333        let markdown = "Para\n\n- item\n\nTerm\n: answer\n";
2334        let html = render_markdown_with_block_lines(markdown, |e| {
2335            matches!(e, Start(Tag::DefinitionListTitle))
2336        });
2337        assert!(html.contains("<p>Para</p>"), "{html}");
2338        assert!(html.contains("<li>item</li>"), "{html}");
2339        assert!(html.contains("<dd>answer</dd>"), "{html}");
2340        assert_eq!(html.matches("data-mbr-line=\"").count(), 1, "{html}");
2341        assert!(html.contains("<dt data-mbr-line="), "{html}");
2342    }
2343
2344    #[test]
2345    fn empty_block_lines_emits_no_attribute() {
2346        let html = render_with_block_lines(
2347            vec![
2348                Start(Tag::Paragraph),
2349                Text(CowStr::Borrowed("p")),
2350                End(TagEnd::Paragraph),
2351            ],
2352            &[],
2353        );
2354        assert_eq!(html, "<p>p</p>\n");
2355        assert!(!html.contains("data-mbr-line"));
2356    }
2357
2358    /// An HTML parser keeps the *first* of a pair of duplicate attributes, so
2359    /// ours must precede any the author wrote.
2360    #[test]
2361    fn our_heading_line_precedes_author_attrs() {
2362        let html =
2363            render_markdown_with_block_lines("## Title {#custom .cls data-mbr-line=99}\n", |e| {
2364                matches!(e, Start(Tag::Heading { .. }))
2365            });
2366        let ours = html.find(r#"data-mbr-line="1""#).expect(&html);
2367        let theirs = html.find(r#"data-mbr-line="99""#).expect(&html);
2368        assert!(
2369            ours < theirs,
2370            "ours must come first so a parser prefers it: {html}"
2371        );
2372        // And it must not have displaced the author's id or classes.
2373        assert!(html.contains(r#"id="custom""#), "{html}");
2374        assert!(html.contains(r#"class="cls""#), "{html}");
2375    }
2376
2377    #[test]
2378    fn mermaid_pre_carries_block_line() {
2379        let html = render_markdown_with_block_lines("```mermaid\ngraph TD\n```\n", |e| {
2380            matches!(e, Start(Tag::CodeBlock(_)))
2381        });
2382        assert!(
2383            html.contains(r#"<pre class="mermaid" data-mbr-line="1">"#),
2384            "{html}"
2385        );
2386        assert!(
2387            !html.contains("<code"),
2388            "mermaid keeps its bare <pre>: {html}"
2389        );
2390    }
2391
2392    #[test]
2393    fn fenced_lang_pre_carries_block_line() {
2394        let html = render_markdown_with_block_lines("```rust\nfn main() {}\n```\n", |e| {
2395            matches!(e, Start(Tag::CodeBlock(_)))
2396        });
2397        assert!(
2398            html.contains(r#"<pre data-mbr-line="1"><code class="language-rust">"#),
2399            "{html}"
2400        );
2401    }
2402
2403    #[test]
2404    fn table_is_wrapped_in_scroll_container() {
2405        let html = render_mbr("| a | b |\n|---|---|\n| 1 | 2 |\n\nafter\n");
2406        let open = r#"<div class="mbr-table-scroll"><table>"#;
2407        assert!(html.contains(open), "{html}");
2408        assert!(html.contains("</tbody></table></div>\n"), "{html}");
2409        // Exactly one wrapper, closed before the next block starts.
2410        assert_eq!(html.matches("mbr-table-scroll").count(), 1, "{html}");
2411        let wrapper_end = html.find("</table></div>").expect("closed wrapper");
2412        let next_block = html.find("<p>after</p>").expect("following paragraph");
2413        assert!(wrapper_end < next_block, "{html}");
2414    }
2415
2416    #[test]
2417    fn every_table_gets_its_own_scroll_container() {
2418        let html = render_mbr("| a |\n|---|\n| 1 |\n\n> | b |\n> |---|\n> | 2 |\n");
2419        assert_eq!(
2420            html.matches(r#"<div class="mbr-table-scroll">"#).count(),
2421            2,
2422            "{html}"
2423        );
2424        assert_eq!(html.matches("</table></div>").count(), 2, "{html}");
2425        assert!(
2426            html.contains("<blockquote>\n<div class=\"mbr-table-scroll\"><table>"),
2427            "a nested table is wrapped where it stands: {html}"
2428        );
2429    }
2430}