Skip to main content

mbr_core/
markdown.rs

1use crate::attrs::ParsedAttrs;
2use crate::errors::MarkdownError;
3use crate::link_index::{OutboundLink, is_internal_link, split_url_anchor};
4use crate::link_transform::{LinkTransformConfig, transform_link};
5use crate::media::MediaEmbed;
6use crate::oembed::PageInfo;
7use crate::oembed_cache::OembedCache;
8use crate::tasks::{self, MarkerRule, TaskStatus};
9use crate::url_path;
10use crate::vid::Vid;
11use crate::wikilink::{parse_tag_link, transform_wikilinks};
12use crate::wikilink_index::WikilinkIndex;
13use pulldown_cmark::{
14    BlockQuoteKind, CodeBlockKind, CowStr, Event, HeadingLevel, LinkType, MetadataBlockKind,
15    Options, Parser as MDParser, Tag, TagEnd, TextMergeStream, TextMergeWithOffset,
16};
17use regex::Regex;
18use std::{
19    borrow::Cow,
20    collections::{BTreeMap, HashMap, HashSet},
21    fs,
22    path::{Path, PathBuf},
23    sync::{Arc, LazyLock},
24};
25use yaml_rust2::{Yaml, YamlLoader};
26
27/// Markdown parser options.
28///
29/// Uses `Options::all()` to enable all pulldown-cmark features including wikilinks.
30///
31/// Wikilink processing flow:
32/// 1. `transform_wikilinks` runs FIRST on raw markdown, converting tag-style wikilinks
33///    like `[[Tags:rust]]` to standard markdown links `[rust](/tags/rust/)`
34/// 2. pulldown-cmark then parses the result, handling plain wikilinks like `[[Whatever]]`
35///    natively with its ENABLE_WIKILINKS support
36///
37/// This hybrid approach allows us to:
38/// - Support custom tag-source links (`[[Source:value]]`)
39/// - Preserve standard wikilink behavior for plain `[[page]]` links
40pub(crate) fn markdown_options() -> Options {
41    Options::all()
42}
43
44/// UTF-8 byte-order mark. Some editors (notably on Windows) prepend it to
45/// markdown files. pulldown-cmark treats it as ordinary content, so a BOM in
46/// front of `---` suppresses the YAML metadata block entirely: frontmatter is
47/// dropped from site.json, search, and the relationship graph, while the page
48/// visibly renders the frontmatter as an em-dash heading. Every entry point
49/// that hands text to the parser strips it first.
50const BOM: char = '\u{feff}';
51
52/// Returns `input` without a leading [`BOM`].
53pub(crate) fn strip_bom(input: &str) -> &str {
54    input.strip_prefix(BOM).unwrap_or(input)
55}
56
57/// Owned counterpart of [`strip_bom`]: removes a leading [`BOM`] in place.
58///
59/// Uses `drain` rather than reallocating, and touches nothing at all for the
60/// overwhelmingly common BOM-less case.
61fn strip_bom_in_place(input: &mut String) {
62    if strip_bom(input).len() != input.len() {
63        input.drain(..BOM.len_utf8());
64    }
65}
66
67/// Loads the first YAML document from `text`, returning `None` when the text
68/// fails to parse *or* contains no document at all.
69///
70/// yaml-rust2 returns `Ok(vec![])` for a metadata block whose body is only
71/// comments (`# tags: [draft]`), so indexing `[0]` on the result panics — and
72/// release builds abort (`panic = 'abort'`). Always go through this helper.
73fn load_first_yaml_doc(text: &str) -> Option<Yaml> {
74    YamlLoader::load_from_str(text)
75        .ok()
76        .and_then(|docs| docs.into_iter().next())
77}
78
79/// Result of parsing a markdown file without rendering to HTML.
80///
81/// Owns the source string so consumers can iterate over events
82/// without lifetime concerns. Use [`events()`](Self::events) to
83/// get the pulldown-cmark event stream.
84#[derive(Debug, Clone)]
85pub struct ParsedDocument {
86    /// The (possibly wikilink-transformed) markdown source.
87    pub source: String,
88    /// Frontmatter metadata extracted from the document.
89    pub frontmatter: SimpleMetadata,
90    /// Table of contents (headings with anchor IDs).
91    pub headings: Vec<HeadingInfo>,
92    /// Whether the document starts with an H1 heading.
93    pub has_h1: bool,
94    /// Word count (excluding code blocks and metadata).
95    pub word_count: usize,
96}
97
98impl ParsedDocument {
99    /// Returns an iterator over pulldown-cmark events for this document.
100    ///
101    /// The events use the same parser options as mbr's HTML renderer,
102    /// ensuring consistent parsing behavior.
103    pub fn events(&self) -> TextMergeStream<'_, MDParser<'_>> {
104        let parser = MDParser::new_ext(&self.source, markdown_options());
105        TextMergeStream::new(parser)
106    }
107}
108
109/// Parse a markdown file into a [`ParsedDocument`] without rendering to HTML.
110///
111/// Reads the file, extracts frontmatter and headings, and returns the parsed
112/// document. Consumers can iterate over the event stream via
113/// [`ParsedDocument::events()`] to render in any format (terminal, HTML, etc.).
114///
115/// Wikilink transforms are not applied (no tag sources configured in this path).
116pub fn parse<P: AsRef<Path>>(file: P) -> Result<ParsedDocument, MarkdownError> {
117    let file = file.as_ref();
118    let mut markdown_input = fs::read_to_string(file).map_err(|e| MarkdownError::ReadFailed {
119        path: file.to_path_buf(),
120        source: e,
121    })?;
122    strip_bom_in_place(&mut markdown_input);
123
124    // Task markup is skipped: this entry point returns an event stream for
125    // callers to render themselves, and only reads the events here for
126    // headings, frontmatter and word counts.
127    let (events, headings, _section_attrs) = collect_events_and_headings(
128        &markdown_input,
129        TaskMarkup::Skip,
130        &mut TextLines::disabled(),
131        &mut BlockLines::disabled(),
132    );
133    let has_h1 = headings.first().is_some_and(|h| h.level == 1);
134
135    // Single pass: extract frontmatter and count words
136    let mut frontmatter = SimpleMetadata::new();
137    let mut word_count: usize = 0;
138    let mut in_yaml = false;
139    let mut in_code_block = false;
140    let mut in_metadata_block = false;
141    for event in &events {
142        match event {
143            Event::Start(Tag::MetadataBlock(MetadataBlockKind::YamlStyle)) => {
144                in_yaml = true;
145                in_metadata_block = true;
146            }
147            Event::End(TagEnd::MetadataBlock(MetadataBlockKind::YamlStyle)) => {
148                in_yaml = false;
149                in_metadata_block = false;
150            }
151            Event::Text(text) if in_yaml => {
152                let metadata_parsed = load_first_yaml_doc(text);
153                frontmatter = yaml_frontmatter_simplified(&metadata_parsed);
154                in_yaml = false;
155            }
156            Event::Start(Tag::MetadataBlock(_)) => in_metadata_block = true,
157            Event::End(TagEnd::MetadataBlock(_)) => in_metadata_block = false,
158            Event::Start(Tag::CodeBlock(_)) => in_code_block = true,
159            Event::End(TagEnd::CodeBlock) => in_code_block = false,
160            Event::Text(text) if !in_code_block && !in_metadata_block => {
161                word_count += text.split_whitespace().count();
162            }
163            _ => {}
164        }
165    }
166
167    if !frontmatter.contains_key("title") && has_h1 {
168        frontmatter.insert(
169            "title".to_string(),
170            serde_json::Value::String(headings[0].text.clone()),
171        );
172    }
173
174    Ok(ParsedDocument {
175        source: markdown_input,
176        frontmatter,
177        headings,
178        has_h1,
179        word_count,
180    })
181}
182
183/// Represents a heading in the document for table of contents generation.
184#[derive(Debug, Clone, serde::Serialize)]
185pub struct HeadingInfo {
186    pub level: u8,
187    pub text: String,
188    pub id: String,
189}
190
191/// Result of rendering a markdown file to HTML.
192///
193/// Contains the rendered HTML along with metadata extracted during parsing.
194#[derive(Debug, Clone)]
195pub struct MarkdownRenderResult {
196    /// Frontmatter metadata (from YAML block at top of file)
197    pub frontmatter: SimpleMetadata,
198    /// YAML frontmatter parse error, if the metadata block failed to parse.
199    ///
200    /// When `Some`, the whole frontmatter map was discarded (yaml-rust2 aborts
201    /// the document on the first error), so valid fields are silently lost.
202    /// Surfaced to the reader via the per-page errors endpoint and to the
203    /// builder via a stderr summary.
204    pub frontmatter_error: Option<String>,
205    /// Table of contents (headings extracted from document)
206    pub headings: Vec<HeadingInfo>,
207    /// Rendered HTML content
208    pub html: String,
209    /// Links discovered during rendering (for backlink tracking)
210    pub outbound_links: Vec<OutboundLink>,
211    /// True if the document's first heading is an H1 (affects title rendering)
212    pub has_h1: bool,
213    /// Word count of the document (excluding code blocks and metadata)
214    pub word_count: usize,
215    /// Sentence count of the document (excluding code blocks and metadata).
216    ///
217    /// Approximated by scanning for terminal punctuation (`.!?`) in text
218    /// events, plus one-per-block for paragraphs, headings, and list items
219    /// whose final text did not end in terminal punctuation.
220    pub sentence_count: usize,
221    /// Syllable count of the document (excluding code blocks and metadata).
222    ///
223    /// Computed via [`crate::readability::count_syllables`] for each
224    /// whitespace-delimited word during rendering.
225    pub syllable_count: usize,
226    /// Bare body wikilinks whose name is shared by several notes, deduped.
227    ///
228    /// Resolution is unchanged (first-wins); these are surfaced to the reader
229    /// via the per-page errors endpoint so an accidental namesake link can be
230    /// spotted. Always empty when the render had no wikilink index (CLI /
231    /// QuickLook paths).
232    pub ambiguous_wikilinks: Vec<crate::wikilink_index::AmbiguousWikilink>,
233    /// The contact card model, for `type: person` / `type: organization` notes.
234    ///
235    /// Parsed here, from the raw YAML, because the simplified `frontmatter`
236    /// above has already lost list-of-map fields such as `phones`. Its `image`
237    /// is rewritten with the same link transform the body's images get, so a
238    /// relative portrait resolves from the page's trailing-slash URL.
239    pub contact: Option<crate::contact::Contact>,
240}
241
242struct EventState {
243    #[allow(dead_code)] // Reserved for future use (resolving relative paths)
244    root_path: PathBuf,
245    /// Path of the file being processed, used to name the file in diagnostics.
246    ///
247    /// Without it a YAML frontmatter warning is unactionable: the reader is told
248    /// *that* a metadata block failed to parse but not *which* of their thousands
249    /// of notes to open.
250    file_path: PathBuf,
251    /// Track the current media embed type (if any) for proper closing tags
252    current_media: Option<MediaEmbed>,
253    in_metadata: bool,
254    in_link: bool, // Track when inside a link (including autolinks like <http://...>)
255    metadata_source: Option<MetadataBlockKind>,
256    metadata_parsed: Option<Yaml>,
257    /// Configuration for transforming relative links
258    link_transform_config: LinkTransformConfig,
259    /// Global name index for Obsidian-style body-wikilink (`[[Name]]`)
260    /// resolution. `None` when there is no repo context (CLI/QuickLook paths);
261    /// only bare wikilinks not found in the current folder consult it.
262    wikilink_index: Option<Arc<WikilinkIndex>>,
263    /// Pre-fetched oembed results for bare URLs (populated during parallel fetch phase)
264    prefetched_oembed: HashMap<String, PageInfo>,
265    /// True in server/GUI mode, false in build/CLI mode
266    server_mode: bool,
267    /// True when dynamic video transcoding is enabled
268    transcode_enabled: bool,
269    /// Collected outbound links from the document
270    collected_links: Vec<OutboundLink>,
271    /// Current link destination URL being processed (set on Start(Link))
272    current_link_dest: Option<String>,
273    /// Current link text being accumulated
274    current_link_text: String,
275    /// Valid tag sources for detecting tag links (e.g., "tags", "performers")
276    valid_tag_sources: HashSet<String>,
277    /// Word count accumulator for text content
278    word_count: usize,
279    /// Track if we're inside a code block (to exclude from word count)
280    in_code_block: bool,
281    /// Sentence count accumulator (via terminal punctuation + block-end bumps)
282    sentence_count: usize,
283    /// Syllable count accumulator (summed per counted word)
284    syllable_count: usize,
285    /// Whether the last observed non-metadata/non-code text ended with
286    /// terminal punctuation. Used to bump `sentence_count` at the end of
287    /// paragraphs, headings, and list items whose final text lacked a `.!?`.
288    block_needs_sentence_bump: bool,
289    /// Captured YAML frontmatter parse error, if the metadata block failed to
290    /// parse. When set, the entire frontmatter was discarded (so otherwise
291    /// valid fields like `style` are lost); surfaced to the user via the
292    /// per-page error reporting and a build-mode summary.
293    frontmatter_error: Option<String>,
294    /// Bare body wikilinks on this page whose name is shared by several notes,
295    /// deduped. Resolution is unaffected; these are reported so the author knows
296    /// mbr picked one arbitrarily.
297    ambiguous_wikilinks: Vec<crate::wikilink_index::AmbiguousWikilink>,
298}
299
300/// Frontmatter metadata as a flat key/value map.
301///
302/// This is a [`BTreeMap`] rather than a `HashMap` so that serialization is
303/// deterministic. `tera`'s `preserve_order` feature turns on
304/// `serde_json/indexmap`, which makes JSON object key order equal *insertion*
305/// order — with a randomly-seeded `HashMap` that made `window.frontmatter` and
306/// every `frontmatter` object in `.mbr/site.json` reshuffle on each run, so two
307/// builds of an identical repository produced different bytes. Ordering was
308/// never YAML source order, so alphabetical is a strict improvement.
309pub type SimpleMetadata = BTreeMap<String, serde_json::Value>;
310
311/// Scan a slice of text for sentence-terminating punctuation (`.!?`).
312///
313/// Returns `(count, ends_with_terminator)` where:
314///
315/// * `count` — the number of in-text sentence terminators, defined as a `.!?`
316///   that is followed by either whitespace or the end of the slice, and which
317///   is not part of a run of terminators (so `...` and `?!` count once).
318/// * `ends_with_terminator` — whether the last non-whitespace character is one
319///   of `.!?`. This is used by the render loop to decide whether to credit
320///   the enclosing block (paragraph/heading/item) with one extra sentence.
321///
322/// The heuristic is intentionally simple: it does not attempt to detect
323/// abbreviations like "Dr." or "e.g." — these false positives are unlikely to
324/// materially shift the FRE/FKGL band for a document of any meaningful length.
325fn count_sentence_terminators(text: &str) -> (usize, bool) {
326    let bytes = text.as_bytes();
327    let mut count: usize = 0;
328    let mut prev_was_terminator = false;
329    for (i, &b) in bytes.iter().enumerate() {
330        let is_terminator = matches!(b, b'.' | b'!' | b'?');
331        if is_terminator && !prev_was_terminator {
332            // Count only when the terminator is followed by whitespace or is
333            // the last non-whitespace character. This avoids counting every
334            // `.` in URLs and numeric contexts.
335            let next_is_boundary = bytes[i + 1..]
336                .iter()
337                .find(|&&c| !matches!(c, b'.' | b'!' | b'?'))
338                .is_none_or(|&c| c.is_ascii_whitespace());
339            if next_is_boundary {
340                count += 1;
341            }
342        }
343        prev_was_terminator = is_terminator;
344    }
345
346    let ends_with_terminator = text
347        .trim_end()
348        .chars()
349        .next_back()
350        .is_some_and(|c| matches!(c, '.' | '!' | '?'));
351
352    (count, ends_with_terminator)
353}
354
355/// Extracts the first H1 heading text from markdown content.
356///
357/// This is used to provide a title fallback when no frontmatter title exists.
358/// Only extracts the first H1 found; subsequent H1s are ignored.
359pub fn extract_first_h1(markdown_input: &str) -> Option<String> {
360    // Use minimal parser options: only YAML metadata (to skip frontmatter blocks)
361    // ATX headings are parsed by default without any feature flags
362    let parser = MDParser::new_ext(markdown_input, Options::ENABLE_YAML_STYLE_METADATA_BLOCKS);
363    let parser = TextMergeStream::new(parser);
364
365    let mut in_h1 = false;
366    let mut h1_text = String::new();
367
368    for event in parser {
369        match event {
370            Event::Start(Tag::Heading {
371                level: HeadingLevel::H1,
372                ..
373            }) => {
374                in_h1 = true;
375            }
376            // Inline code spans are part of the visible heading text, so a
377            // title like "# The `main` function" must not lose the code word.
378            Event::Text(text) | Event::Code(text) if in_h1 => {
379                h1_text.push_str(&text);
380            }
381            Event::End(TagEnd::Heading(HeadingLevel::H1)) => {
382                if !h1_text.is_empty() {
383                    return Some(h1_text);
384                }
385                in_h1 = false;
386            }
387            _ => {}
388        }
389    }
390    None
391}
392
393/// Em dash character (U+2014) - what `---` becomes with smart punctuation
394const EM_DASH: &str = "\u{2014}";
395
396/// The `{…}` attribute block of a `--- {attrs}` section rule, given the text
397/// of the paragraph pulldown-cmark makes of that line.
398///
399/// `---` followed by anything is not a thematic break in CommonMark, so the
400/// line arrives as paragraph text, and smart punctuation has already turned
401/// the `---` into an em dash: `— {#id .class}`. A paragraph whose whole
402/// (merged) text matches is a section rule — even when the attributes do not
403/// parse, in which case the rule simply has none. The single definition of
404/// the pattern, shared by the renderer and the editor parity outline.
405pub(crate) fn section_rule_attrs(text: &str) -> Option<&str> {
406    text.strip_prefix(EM_DASH)
407        .filter(|rest| rest.starts_with(" {") && rest.ends_with('}'))
408        .map(str::trim)
409}
410
411/// Maps a non-standard [remark-hint](https://github.com/sergioramos/remark-hint)
412/// paragraph prefix to its GitHub-alert equivalent.
413///
414/// Returns the [`BlockQuoteKind`] and the text with the marker stripped, or
415/// `None` when the text does not begin with a recognized hint marker.
416fn detect_hint_prefix(text: &str) -> Option<(BlockQuoteKind, &str)> {
417    // Dispatch on the first byte so the common (non-hint) paragraph bails out after
418    // a single comparison instead of attempting every prefix.
419    let (prefix, kind) = match text.as_bytes().first()? {
420        b'!' => ("!> ", BlockQuoteKind::Tip),
421        b'?' => ("?> ", BlockQuoteKind::Warning),
422        b'x' => ("x> ", BlockQuoteKind::Caution),
423        _ => return None,
424    };
425    text.strip_prefix(prefix).map(|rest| (kind, rest))
426}
427
428/// Transform events: detect `--- {attrs}` pattern and convert to Rule + attrs.
429///
430/// When pulldown-cmark (with TextMergeStream) sees `--- {#id .class}` on a single line,
431/// it produces:
432/// - Start(Paragraph)
433/// - Text("— {#id .class}") (em dash + space + attrs, merged into one Text)
434/// - End(Paragraph)
435///
436/// This function detects that pattern and transforms it into a single Rule event,
437/// extracting the attributes for section rendering.
438///
439/// Returns (transformed_events, section_attrs) where section_attrs maps section
440/// index to parsed attributes.
441///
442/// Note: This logic is now inlined into `collect_events_and_headings` for the main
443/// render path. This standalone function is kept for potential standalone use.
444#[allow(dead_code)]
445fn transform_rule_attrs(events: Vec<Event<'_>>) -> (Vec<Event<'_>>, HashMap<usize, ParsedAttrs>) {
446    let mut result = Vec::with_capacity(events.len());
447    let mut section_attrs = HashMap::new();
448    let mut section_index = 0;
449    let mut i = 0;
450
451    while i < events.len() {
452        // Detect pattern: Start(Paragraph), Text("— {attrs}"), End(Paragraph)
453        // TextMergeStream merges adjacent Text events, so we see a single Text event
454        if i + 2 < events.len()
455            && let (Event::Start(Tag::Paragraph), Event::Text(text), Event::End(TagEnd::Paragraph)) =
456                (&events[i], &events[i + 1], &events[i + 2])
457            // Check: text starts with em dash + space + "{" and ends with "}"
458            && text.starts_with(EM_DASH)
459            && let Some(attrs_str) = text.strip_prefix(EM_DASH)
460            && attrs_str.starts_with(" {")
461            && attrs_str.ends_with('}')
462            && let Some(attrs) = ParsedAttrs::parse(attrs_str.trim())
463        {
464            // Transform: emit Rule instead of paragraph
465            result.push(Event::Rule);
466            section_index += 1;
467            section_attrs.insert(section_index, attrs);
468            i += 3; // Skip all 3 events
469            continue;
470        }
471
472        // Track real Rule events for section counting
473        if matches!(&events[i], Event::Rule) {
474            section_index += 1;
475        }
476
477        result.push(events[i].clone());
478        i += 1;
479    }
480
481    (result, section_attrs)
482}
483
484/// Byte offset → 1-based line number lookups over a markdown source.
485///
486/// Built once per document and only when the document actually contains a task
487/// checkbox, so prose-only pages — the overwhelming majority — never pay for
488/// the scan. Lookups binary-search rather than counting newlines per marker,
489/// which keeps a document of a thousand tasks from becoming quadratic.
490pub(crate) struct LineIndex {
491    /// Byte offset of every `\n`, ascending.
492    newlines: Vec<usize>,
493}
494
495/// Bytes per line assumed when sizing a [`LineIndex`]. Markdown is prose, so
496/// this is a close enough guess that the vector rarely has to grow.
497const ASSUMED_LINE_BYTES: usize = 32;
498
499/// Ceiling on that guess, so a file that is one enormous line (a minified blob
500/// with a `.md` extension, say) cannot reserve megabytes it will never use.
501const MAX_RESERVED_LINES: usize = 1 << 16;
502
503impl LineIndex {
504    pub(crate) fn build(source: &str) -> Self {
505        // `match_indices` over a `char` pattern takes the standard library's
506        // vectorised byte search, which measured ~5x faster than the obvious
507        // `bytes().enumerate().filter(..)` loop (32.5us against 6.4us on a
508        // 60 kB document) -- and that loop was, before this, the single largest
509        // cost of task rendering on a large page.
510        let mut newlines =
511            Vec::with_capacity((source.len() / ASSUMED_LINE_BYTES).min(MAX_RESERVED_LINES));
512        newlines.extend(source.match_indices('\n').map(|(offset, _)| offset));
513        Self { newlines }
514    }
515
516    /// The 1-based line containing byte `offset`.
517    pub(crate) fn line_of(&self, offset: usize) -> u32 {
518        // `partition_point` counts the newlines strictly before `offset`, which
519        // is the number of complete lines preceding it.
520        let preceding = self.newlines.partition_point(|&newline| newline < offset);
521        u32::try_from(preceding + 1).unwrap_or(u32::MAX)
522    }
523}
524
525/// The source line one `Event::Text` came from, addressed by its slot in the
526/// pass-1 output.
527struct TextLine {
528    /// Index of the event in the vector [`collect_events_and_headings`] returns.
529    at: u32,
530    /// 1-based source line the run starts on.
531    line: u32,
532}
533
534/// Source lines for the text runs of a document, carried from pass 1
535/// ([`collect_events_and_headings`], the only place byte ranges exist) to pass 3
536/// ([`mark_incomplete_blocks`], which needs them for `#mbr-marker-{line}`
537/// anchors and has no ranges of its own).
538///
539/// # Why an event index is enough
540///
541/// [`process_all_events`] sits between the two passes and is a strict 1:1 map
542/// (see its `# Invariant`), so a slot recorded in pass 1 still holds the same
543/// run in pass 3.
544///
545/// # Why the run's *start* is enough
546///
547/// `SoftBreak` and `HardBreak` are their own events and therefore terminate a
548/// [`TextMergeWithOffset`] merge, so outside code blocks a merged `Event::Text`
549/// never spans a source line break. Every marker in a run is on the run's first
550/// line, which means no offset-into-text mapping is needed — and that matters,
551/// because [`markdown_options`] enables smart punctuation, so `--` → `–` and
552/// `"` → `“` have already desynchronised text bytes from source bytes by the
553/// time pass 3 sees them. (Code-block text *is* the one run that spans
554/// newlines; pass 3 skips it for exactly that reason.)
555///
556/// # Why every run is recorded
557///
558/// Recording only runs in marker-eligible blocks would mean restating pass 3's
559/// frame rules 300 lines away with nothing forcing the two to agree. One
560/// 8-byte record per run is cheap, and it is only paid when recording is on.
561struct TextLines {
562    /// Ascending by `at`.
563    entries: Vec<TextLine>,
564    /// False for the callers that never run pass 3, which then never build a
565    /// [`LineIndex`] either. `extract_outbound_links_sync` depends on this: it
566    /// runs over every markdown file in the repository to build the backlink
567    /// index.
568    enabled: bool,
569}
570
571impl TextLines {
572    /// A table that will be filled in — for a render that will run pass 3.
573    fn recording() -> Self {
574        Self {
575            entries: Vec::new(),
576            enabled: true,
577        }
578    }
579
580    /// A table that stays empty, and whose `record` calls compile down to a
581    /// single branch.
582    fn disabled() -> Self {
583        Self {
584            entries: Vec::new(),
585            enabled: false,
586        }
587    }
588
589    /// Notes that the event about to occupy slot `at` is a text run beginning on
590    /// `line`. A no-op when disabled or when `line` is `None`.
591    fn record(&mut self, at: usize, line: Option<u32>) {
592        if !self.enabled {
593            return;
594        }
595        let Some(line) = line else {
596            return;
597        };
598        let at = u32::try_from(at).unwrap_or(u32::MAX);
599        debug_assert!(
600            self.entries.last().is_none_or(|last| last.at < at),
601            "text-line records must be strictly ascending; the monotone cursor \
602             that reads them back cannot recover from a repeat or a rewind"
603        );
604        self.entries.push(TextLine { at, line });
605    }
606
607    /// Drops the records for slots at or beyond `len`, after the caller has
608    /// shortened the event vector.
609    fn truncate_to(&mut self, len: usize) {
610        // Ascending by `at`, so the split point is a binary search.
611        let keep = self
612            .entries
613            .partition_point(|entry| (entry.at as usize) < len);
614        self.entries.truncate(keep);
615    }
616
617    fn cursor(&self) -> TextLineCursor<'_> {
618        TextLineCursor {
619            entries: &self.entries,
620            next: 0,
621        }
622    }
623}
624
625/// Reads a [`TextLines`] table back in ascending index order.
626///
627/// A cursor rather than a binary search per lookup: pass 3 walks the event
628/// vector from the front, so the queries are non-decreasing and each one costs
629/// O(1) amortised.
630struct TextLineCursor<'a> {
631    entries: &'a [TextLine],
632    next: usize,
633}
634
635impl TextLineCursor<'_> {
636    /// The source line of the text run at `index`, or `None` when nothing was
637    /// recorded for it.
638    ///
639    /// `index` must not go backwards between calls.
640    fn line_at(&mut self, index: usize) -> Option<u32> {
641        while self
642            .entries
643            .get(self.next)
644            .is_some_and(|entry| (entry.at as usize) < index)
645        {
646            self.next += 1;
647        }
648        self.entries
649            .get(self.next)
650            .filter(|entry| entry.at as usize == index)
651            .map(|entry| entry.line)
652    }
653}
654
655/// Whether the render should emit `data-mbr-line` source lines on block-level
656/// elements, for a frontend feature that anchors a text selection to
657/// `file.md:LINE`.
658///
659/// An enum rather than a fourth `bool`: the render entry points already take
660/// three positional booleans under `#[allow(clippy::too_many_arguments)]`, and a
661/// fourth would be swappable with any of them without the compiler noticing.
662/// Same reasoning, and the same shape, as [`TaskMarkup`].
663///
664/// Three states because two features read the attribute. Review notes read it
665/// off every block in [`is_review_block_start`]; flashcard reviews
666/// (`POST /.mbr/flashcard-review`) address a card by its **term's** line alone,
667/// and they answer to `edit_enabled`, not `review_enabled`. Folding both into
668/// one boolean meant `--no-review` silently took spaced repetition away from a
669/// server that had editing on.
670#[derive(Debug, Clone, Copy, PartialEq, Eq)]
671pub enum ReviewLines {
672    /// Every block in [`is_review_block_start`].
673    Emit,
674    /// `<dt>` only ([`is_definition_term_start`]): review notes are off, but a
675    /// flashcard review still needs to name its card. Every other element is
676    /// exactly what [`ReviewLines::Omit`] renders, so `--no-review` keeps its
677    /// meaning everywhere else.
678    TermsOnly,
679    /// Nothing; byte-identical to a render from before the attribute existed.
680    Omit,
681}
682
683impl ReviewLines {
684    /// The mode for a live page render, from the two switches that read the
685    /// attribute. `review_enabled` wins because it is a superset: its blocks
686    /// already include every `<dt>`.
687    pub fn for_server(review_enabled: bool, edit_enabled: bool) -> Self {
688        match (review_enabled, edit_enabled) {
689            (true, _) => Self::Emit,
690            (false, true) => Self::TermsOnly,
691            (false, false) => Self::Omit,
692        }
693    }
694}
695
696/// The **single** definition of which blocks carry a `data-mbr-line` attribute.
697///
698/// `src/html.rs` renders the attribute; its eight `start_tag` arms name this
699/// function in their comments so the two cannot drift apart.
700///
701/// # What is deliberately excluded
702///
703/// * **`Tag::List`** (`<ul>`/`<ol>`) — a list's source line is always its first
704///   `<li>`'s, so an attribute there says nothing the item does not.
705/// * **`Tag::TableCell`** (`<td>`/`<th>`) — every cell in a row shares one
706///   source line, so per-cell attributes roughly triple the byte cost of a
707///   table-heavy document to repeat the row's line over and over. The `<table>`
708///   carries the line instead.
709/// * **Inline tags** (`<em>`, `<a>`, …) — anchoring one would need a byte offset
710///   *within* a text run, and smart punctuation has already desynchronised text
711///   bytes from source bytes by the time the writer sees them (see
712///   [`TextLines`]' "Why the run's *start* is enough").
713///
714/// # Definition lists
715///
716/// `<dt>` and `<dd>` are in the set because a **tight** definition list's prose
717/// is not wrapped in a `<p>`, so without them such a line has no ancestor
718/// carrying `data-mbr-line` at all. The `<dt>`'s line is also how a flashcard
719/// review addresses its card (`POST /.mbr/flashcard-review`, `flashcards.rs`):
720/// the term's own source line is the `line`/`expected` pair the write checks.
721fn is_review_block_start(event: &Event<'_>) -> bool {
722    matches!(
723        event,
724        Event::Start(
725            Tag::Paragraph
726                | Tag::Heading { .. }
727                | Tag::Item
728                | Tag::BlockQuote(_)
729                | Tag::CodeBlock(_)
730                | Tag::Table(_)
731                | Tag::DefinitionListTitle
732                | Tag::DefinitionListDefinition
733        )
734    )
735}
736
737/// The subset of [`is_review_block_start`] that [`ReviewLines::TermsOnly`]
738/// records: a definition-list term, the line a flashcard review addresses.
739fn is_definition_term_start(event: &Event<'_>) -> bool {
740    matches!(event, Event::Start(Tag::DefinitionListTitle))
741}
742
743/// Source lines for the block-level elements of a document, carried from pass 1
744/// ([`collect_events_and_headings`], the only place byte ranges exist) to the
745/// HTML writer, which emits them as `data-mbr-line`.
746///
747/// # Why an event index is enough
748///
749/// The same reason [`TextLines`] gives: [`process_all_events`] is a strict 1:1
750/// map (see its `# Invariant`), so a slot recorded in pass 1 still holds the
751/// same event afterwards.
752///
753/// # …until pass 3 rewrites it
754///
755/// [`mark_incomplete_blocks`] is **not** 1:1 — it pushes highlight spans and, in
756/// one case, *inserts* one behind an already-emitted `Start`. It therefore
757/// rebuilds this table against its own output vector, and what reaches the
758/// writer is keyed on output indices, not pass-1 ones. See that function's
759/// `# Index alignment`.
760///
761/// # Why a separate table from [`TextLines`]
762///
763/// The two answer to independent switches — `TextLines` to `mark_incomplete`,
764/// this to [`ReviewLines`] (`review_enabled`, and `edit_enabled` for flashcard
765/// terms) — so neither flag can serve both, and a single monotone
766/// [`TextLineCursor`] cannot serve two consumers walking different vectors.
767///
768/// # Scope is decided here, not in the writer
769///
770/// `html.rs` numbers exactly the events this table names, so a
771/// [`ReviewLines::TermsOnly`] table — which only ever *records* `<dt>` starts —
772/// needs nothing from the writer, and pass 3's remap carries it unchanged
773/// because it re-keys whatever records exist rather than re-deciding which
774/// blocks deserve one.
775struct BlockLines {
776    /// Ascending by `at`, strictly.
777    entries: Vec<crate::html::BlockLine>,
778    /// False for every caller that does not want `data-mbr-line`, whose
779    /// `record` calls then compile down to a single branch. Builds, the CLI,
780    /// QuickLook and the repository-wide backlink scan are all disabled.
781    enabled: bool,
782    /// Record `<dt>` starts only ([`ReviewLines::TermsOnly`]).
783    terms_only: bool,
784}
785
786impl BlockLines {
787    /// The table a render in `mode` fills in.
788    fn for_mode(mode: ReviewLines) -> Self {
789        Self {
790            entries: Vec::new(),
791            enabled: mode != ReviewLines::Omit,
792            terms_only: mode == ReviewLines::TermsOnly,
793        }
794    }
795
796    /// A table that will be filled in — for a render that wants `data-mbr-line`.
797    #[cfg(test)]
798    fn recording() -> Self {
799        Self::for_mode(ReviewLines::Emit)
800    }
801
802    /// A table that stays empty. An empty table is also how `html.rs` knows the
803    /// feature is off, so a disabled render costs one branch and emits nothing.
804    fn disabled() -> Self {
805        Self::for_mode(ReviewLines::Omit)
806    }
807
808    /// A fresh, empty table in the same mode — what pass 3 rebuilds into.
809    fn empty_like(&self) -> Self {
810        Self {
811            entries: Vec::new(),
812            enabled: self.enabled,
813            terms_only: self.terms_only,
814        }
815    }
816
817    /// Whether `event` is a block start this table records a line for.
818    ///
819    /// `enabled` first, so the off path is one predictable branch rather than
820    /// the eight-variant `matches!` behind it.
821    fn wants(&self, event: &Event<'_>) -> bool {
822        self.enabled
823            && if self.terms_only {
824                is_definition_term_start(event)
825            } else {
826                is_review_block_start(event)
827            }
828    }
829
830    /// Whether every recorded block kind is in scope — i.e. review notes are
831    /// on. Lines emitted *outside* this table (the chat container's) key off it,
832    /// so a terms-only render numbers nothing but `<dt>`.
833    fn numbers_every_block(&self) -> bool {
834        self.enabled && !self.terms_only
835    }
836
837    /// Notes that the event about to occupy slot `at` is a block start on
838    /// `line`. A no-op when disabled or when `line` is `None`.
839    fn record(&mut self, at: usize, line: Option<u32>) {
840        if !self.enabled {
841            return;
842        }
843        let Some(line) = line else {
844            return;
845        };
846        let at = u32::try_from(at).unwrap_or(u32::MAX);
847        debug_assert!(
848            self.entries.last().is_none_or(|last| last.at < at),
849            "block-line records must be strictly ascending; the monotone cursors \
850             that read them back cannot recover from a repeat or a rewind"
851        );
852        self.entries.push(crate::html::BlockLine { at, line });
853    }
854
855    /// The line recorded for slot `at`, if any.
856    ///
857    /// For a pass-1 rewrite that replaces a recorded block start with another:
858    /// the replacement inherits the original's line from *this* table, so its
859    /// numbering depends only on `BlockLines`'s own gate, never on whether
860    /// [`TextLines`] happened to compute a line for the triggering event.
861    fn line_recorded_at(&self, at: usize) -> Option<u32> {
862        let at = u32::try_from(at).ok()?;
863        self.entries
864            .last()
865            .filter(|entry| entry.at == at)
866            .map(|entry| entry.line)
867    }
868
869    /// Shifts every record at or after `at` up by one, after the caller has
870    /// *inserted* an event at `at`.
871    ///
872    /// Strict ascension survives: the last untouched entry is `< at`, and the
873    /// first bumped one was `>= at`, so it lands `> at` and stays above it.
874    fn shift_from(&mut self, at: usize) {
875        let at = u32::try_from(at).unwrap_or(u32::MAX);
876        let first = self.entries.partition_point(|entry| entry.at < at);
877        for entry in &mut self.entries[first..] {
878            entry.at += 1;
879        }
880    }
881
882    /// Drops the records for slots at or beyond `len`, after the caller has
883    /// shortened the event vector.
884    fn truncate_to(&mut self, len: usize) {
885        let keep = self
886            .entries
887            .partition_point(|entry| (entry.at as usize) < len);
888        self.entries.truncate(keep);
889    }
890
891    fn cursor(&self) -> BlockLineCursor<'_> {
892        BlockLineCursor {
893            entries: &self.entries,
894            next: 0,
895        }
896    }
897
898    /// Hands the table to [`crate::html::HtmlConfig`], which owns it from here.
899    fn into_entries(self) -> Vec<crate::html::BlockLine> {
900        self.entries
901    }
902}
903
904/// Reads a [`BlockLines`] table back in ascending index order.
905///
906/// A twin of [`TextLineCursor`] over the other record type, and for the same
907/// reason: pass 3 walks the event vector from the front, so the queries are
908/// non-decreasing and each costs O(1) amortised.
909struct BlockLineCursor<'a> {
910    entries: &'a [crate::html::BlockLine],
911    next: usize,
912}
913
914impl BlockLineCursor<'_> {
915    /// The source line of the block start at `index`, or `None` when nothing was
916    /// recorded for it.
917    ///
918    /// `index` must not go backwards between calls.
919    fn line_at(&mut self, index: usize) -> Option<u32> {
920        while self
921            .entries
922            .get(self.next)
923            .is_some_and(|entry| (entry.at as usize) < index)
924        {
925            self.next += 1;
926        }
927        self.entries
928            .get(self.next)
929            .filter(|entry| entry.at as usize == index)
930            .map(|entry| entry.line)
931    }
932}
933
934/// Pushes `event`, recording `line` against its slot when it is an
935/// `Event::Text` (for [`TextLines`]) or a block start (for [`BlockLines`]).
936///
937/// Every push in [`collect_events_and_headings`] goes through this, so a future
938/// arm cannot forget to record and leave pass 3 anchoring a marker to the wrong
939/// line. `line` is `None` for every event that is not the current input event
940/// (or, for the remark-hint rewrite, the block start it replaces), which makes
941/// the guards here belt-and-braces rather than the only check.
942///
943/// The two record kinds are mutually exclusive — a `Start` is not a `Text` — so
944/// one `else if` covers both tables.
945fn push_event<'a>(
946    events: &mut Vec<Event<'a>>,
947    text_lines: &mut TextLines,
948    block_lines: &mut BlockLines,
949    event: Event<'a>,
950    line: Option<u32>,
951) {
952    if matches!(event, Event::Text(_)) {
953        text_lines.record(events.len(), line);
954    } else if block_lines.wants(&event) {
955        block_lines.record(events.len(), line);
956    }
957    events.push(event);
958}
959
960/// Pops the last event, keeping the [`TextLines`] and [`BlockLines`] tables
961/// addressed to the shortened vector.
962///
963/// For [`TextLines`] this is belt-and-braces. The rule-attrs rewrite pops a
964/// *recorded* `Text` and reuses its slot for a `Rule`, so without this the table
965/// would hold a record for an event that is no longer there. As the grammar
966/// stands today the stale record is inert — [`TextLineCursor`] matches on an
967/// exact index and the vacated slot is always refilled by a `Start`, never by
968/// another `Text` — but "inert" here depends on pulldown-cmark never emitting a
969/// `Text` straight after a thematic break, which is not a promise anyone made
970/// us.
971///
972/// For [`BlockLines`] it is **load-bearing**, not belt-and-braces: both pop
973/// sites pop a recorded `Start(Tag::Paragraph)`. The remark-hint rewrite
974/// replaces one with a `Start(BlockQuote)` — also a recorded block — and the
975/// rule-attrs rewrite replaces one with an unrecorded `Event::Rule`. Without the
976/// truncation the first would trip `BlockLines::record`'s ascending assertion,
977/// and the second would leave a stale record pointing at whatever later refilled
978/// the slot, putting the paragraph's line on an unrelated element.
979///
980/// Both pop sites go through this, so a future edit cannot reintroduce the
981/// hazard by moving a `push` above a `pop`.
982fn pop_event<'a>(
983    events: &mut Vec<Event<'a>>,
984    text_lines: &mut TextLines,
985    block_lines: &mut BlockLines,
986) -> Option<Event<'a>> {
987    let popped = events.pop();
988    text_lines.truncate_to(events.len());
989    block_lines.truncate_to(events.len());
990    popped
991}
992
993/// Whether [`collect_events_and_headings`] should also rewrite task list items
994/// into mbr's checkbox-and-chips markup.
995///
996/// [`TaskMarkup::Skip`] exists for the callers that only want the event stream:
997/// the repository-wide backlink scan runs over every markdown file in the
998/// repository, and running the annotation grammar over every task line of every
999/// one of them buys it nothing — it collects link destinations, which this
1000/// rewrite never touches.
1001#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1002enum TaskMarkup {
1003    Render,
1004    Skip,
1005}
1006
1007/// Decodes a task checkbox from `event`, or `None` if it is not one.
1008///
1009/// `at_item_start` must be true only when `event` is the first inline event of
1010/// a list item. That is the sole position where the non-standard `[-]` and
1011/// `[>]` markers are recognised, and gating on it is also what keeps a `[-]`
1012/// written inside a fenced code block, a heading, or ordinary prose from
1013/// turning into a checkbox — none of those is a list item's first inline event.
1014fn task_marker_status(event: &Event<'_>, at_item_start: bool) -> Option<TaskStatus> {
1015    match event {
1016        // pulldown-cmark recognises `[ ]` and `[x]` natively.
1017        Event::TaskListMarker(true) => Some(TaskStatus::Done),
1018        Event::TaskListMarker(false) => Some(TaskStatus::Open),
1019        Event::Text(text) if at_item_start => split_extended_marker(text).map(|(status, _)| status),
1020        _ => None,
1021    }
1022}
1023
1024/// Splits mbr's `[-]` (canceled) or `[>]` (moved elsewhere) marker off the
1025/// front of a list item's first text run, returning the status and the text
1026/// that follows it.
1027///
1028/// pulldown-cmark only understands `[ ]` and `[x]`, so these two reach the
1029/// renderer as ordinary text and mbr has to pick them out itself. The marker
1030/// must be followed by whitespace or end the run, matching the grammar in
1031/// [`crate::tasks`], so `[-]x` is not a checkbox.
1032pub(crate) fn split_extended_marker(text: &str) -> Option<(TaskStatus, &str)> {
1033    let rest = text
1034        .strip_prefix("[-]")
1035        .or_else(|| text.strip_prefix("[>]"))?;
1036    match rest.as_bytes().first() {
1037        None => Some((TaskStatus::Canceled, rest)),
1038        // One ASCII byte, so the split is always on a char boundary.
1039        Some(b' ' | b'\t') => Some((TaskStatus::Canceled, &rest[1..])),
1040        Some(_) => None,
1041    }
1042}
1043
1044/// True for events that belong to a block's inline content.
1045///
1046/// A task's text span closes at the first event that is not one of these — a
1047/// nested `Start(List)`, the item's `End(Paragraph)`, or its `End(Item)` — so
1048/// the annotation chips land at the end of the task's own line rather than
1049/// after its subtasks.
1050fn is_inline_event(event: &Event<'_>) -> bool {
1051    match event {
1052        Event::Text(_)
1053        | Event::Code(_)
1054        | Event::InlineMath(_)
1055        | Event::DisplayMath(_)
1056        | Event::InlineHtml(_)
1057        | Event::FootnoteReference(_)
1058        | Event::SoftBreak
1059        | Event::HardBreak
1060        | Event::TaskListMarker(_) => true,
1061        Event::Start(tag) => matches!(
1062            tag,
1063            Tag::Emphasis
1064                | Tag::Strong
1065                | Tag::Strikethrough
1066                | Tag::Superscript
1067                | Tag::Subscript
1068                | Tag::Link { .. }
1069                | Tag::Image { .. }
1070        ),
1071        Event::End(tag) => matches!(
1072            tag,
1073            TagEnd::Emphasis
1074                | TagEnd::Strong
1075                | TagEnd::Strikethrough
1076                | TagEnd::Superscript
1077                | TagEnd::Subscript
1078                | TagEnd::Link
1079                | TagEnd::Image
1080        ),
1081        // `Event::Html` is a raw *block*, `Event::Rule` a thematic break.
1082        _ => false,
1083    }
1084}
1085
1086/// Merged pass 1: parse markdown, extract headings with anchor IDs, detect
1087/// `--- {attrs}` rule patterns, and rewrite task list items -- all in a single
1088/// iteration over the parser output.
1089///
1090/// Returns (events, headings, section_attrs).
1091///
1092/// This merges what were previously separate passes (heading extraction loop +
1093/// `transform_rule_attrs`) into one. The rule-attrs detection uses a 3-element
1094/// look-back buffer: when we encounter `End(Paragraph)`, we check if the preceding
1095/// two events form the `Start(Paragraph), Text("em-dash + attrs")` pattern.
1096///
1097/// Task rewriting is folded in here rather than run as its own pass for two
1098/// reasons. The source line each checkbox came from is only knowable from the
1099/// parser's byte ranges, which exist nowhere else; and a separate pass would
1100/// have to allocate and move a second copy of the whole event vector, which
1101/// measured as most of the cost of the feature on a task-heavy document.
1102/// Everything else discards its range immediately.
1103///
1104/// # Task output shape
1105///
1106/// For `- [ ] fix **this** !! #bug @due(2026-08-05)` on line 1:
1107///
1108/// ```html
1109/// <li><input type="checkbox" class="mbr-task-check" id="mbr-task-1"
1110///            data-mbr-task-line="1" data-mbr-task-status="open" disabled>
1111/// <span class="mbr-task-text">fix <strong>this</strong></span>
1112/// <span class="mbr-task-pri mbr-task-pri-high" …></span>
1113/// <span class="mbr-task-tag">#bug</span>
1114/// <time class="mbr-task-due" datetime="2026-08-05">Aug 5</time></li>
1115/// ```
1116///
1117/// # Caveat: offsets are into the transformed source
1118///
1119/// The ranges index `markdown_input`, which is the wikilink-substituted source
1120/// rather than the file on disk. Line numbers survive that substitution because
1121/// `transform_wikilinks` only ever rewrites within a single line: a wikilink
1122/// that spans a line break is not a wikilink (see `wikilink::push_transformed`),
1123/// so no rewrite can add or remove a newline.
1124///
1125/// # Text lines and block lines
1126///
1127/// `text_lines` and `block_lines` are out-parameters rather than further tuple
1128/// fields: the return type is already at the limit of what reads. Each is filled
1129/// only when the caller passes a `recording()` table — [`TextLines`] exactly
1130/// when [`mark_incomplete_blocks`] will run, [`BlockLines`] exactly when the
1131/// page wants `data-mbr-line`. The two switches are independent, which is why
1132/// they are two tables.
1133fn collect_events_and_headings<'a>(
1134    markdown_input: &'a str,
1135    task_markup: TaskMarkup,
1136    text_lines: &mut TextLines,
1137    block_lines: &mut BlockLines,
1138) -> (
1139    Vec<Event<'a>>,
1140    Vec<HeadingInfo>,
1141    HashMap<usize, ParsedAttrs>,
1142) {
1143    let parser = MDParser::new_ext(markdown_input, markdown_options()).into_offset_iter();
1144    let parser = TextMergeWithOffset::new(parser);
1145
1146    let mut events = Vec::new();
1147    let mut headings = Vec::new();
1148    let mut anchor_ids: HashMap<String, usize> = HashMap::new();
1149    let mut in_heading_text: Option<String> = None;
1150    let mut section_attrs = HashMap::new();
1151    let mut section_index = 0;
1152    let mut hint_open = false;
1153    // Built on the first checkbox, so a document without tasks never scans for
1154    // newlines at all.
1155    let mut line_index: Option<LineIndex> = None;
1156    let mut at_item_start = false;
1157    let mut pending_task: Option<PendingTask> = None;
1158    let mut chat_block: Option<PendingChat> = None;
1159
1160    for (event, range) in parser {
1161        // Computed once here rather than at each push site, and only when
1162        // somebody is going to read it: building the `LineIndex` is the whole
1163        // cost, and a document nobody will highlight or anchor must not pay it.
1164        // Either table wanting this event's line is enough to build the index —
1165        // and once built it is reused, so the two features together cost no more
1166        // than one.
1167        let source_line = if (text_lines.enabled && matches!(event, Event::Text(_)))
1168            || block_lines.wants(&event)
1169        {
1170            let index = line_index.get_or_insert_with(|| LineIndex::build(markdown_input));
1171            Some(index.line_of(range.start))
1172        } else {
1173            None
1174        };
1175        let was_at_item_start = at_item_start;
1176        // A loose list item wraps its content in a paragraph, so the marker
1177        // arrives one event later there than in a tight list.
1178        at_item_start = matches!(event, Event::Start(Tag::Item))
1179            || (at_item_start && matches!(event, Event::Start(Tag::Paragraph)));
1180
1181        if task_markup == TaskMarkup::Render {
1182            if let Some(status) = task_marker_status(&event, was_at_item_start) {
1183                // A marker always opens a fresh item, so nothing should still
1184                // be open; closing defensively beats emitting a stray `<span>`.
1185                if let Some(open) = pending_task.take() {
1186                    close_task(&mut events, open);
1187                }
1188                let index = line_index.get_or_insert_with(|| LineIndex::build(markdown_input));
1189                let line = index.line_of(range.start);
1190
1191                push_event(
1192                    &mut events,
1193                    text_lines,
1194                    block_lines,
1195                    Event::Html(CowStr::from(crate::html::task_checkbox_html(
1196                        status,
1197                        Some(line),
1198                    ))),
1199                    source_line,
1200                );
1201                push_event(
1202                    &mut events,
1203                    text_lines,
1204                    block_lines,
1205                    Event::Html(CowStr::from(crate::html::task_text_open(status))),
1206                    source_line,
1207                );
1208
1209                let mut task = PendingTask {
1210                    text_at: Vec::new(),
1211                };
1212                // `[-]` / `[>]` are not parser-recognised markers: the checkbox
1213                // and the first run of display text arrive as one text event, so
1214                // the remainder has to be put back.
1215                if let Event::Text(text) = &event
1216                    && let Some((_, rest)) = split_extended_marker(text)
1217                {
1218                    task.text_at.push(events.len());
1219                    // The remainder is a slice of the same run, so it starts on
1220                    // the same source line the run did.
1221                    push_event(
1222                        &mut events,
1223                        text_lines,
1224                        block_lines,
1225                        Event::Text(CowStr::from(rest.to_string())),
1226                        source_line,
1227                    );
1228                }
1229                pending_task = Some(task);
1230                // The marker event itself is replaced, so it is never pushed.
1231                continue;
1232            }
1233
1234            if let Some(task) = pending_task.as_mut() {
1235                if is_inline_event(&event) {
1236                    // Pushed here rather than falling through to the match, so
1237                    // the recorded index is provably the slot the run lands in.
1238                    // None of the arms below applies to a task's inline content:
1239                    // the heading arms need an open heading and the hint arm a
1240                    // `Start(Paragraph)` immediately behind, which the span-open
1241                    // event displaces.
1242                    if matches!(event, Event::Text(_)) {
1243                        task.text_at.push(events.len());
1244                    }
1245                    push_event(&mut events, text_lines, block_lines, event, source_line);
1246                    continue;
1247                }
1248                // End of the task's own line: close the text span and emit the
1249                // chips before whatever block comes next (a nested subtask
1250                // list, the item's end, a second paragraph).
1251                if let Some(task) = pending_task.take() {
1252                    close_task(&mut events, task);
1253                }
1254            }
1255        }
1256
1257        // --- ```chat blocks (see `push_chat_block`) ---
1258        // Placed after the task handling so a fence inside a task item has
1259        // already closed the task's span, exactly as any code block does.
1260        if let Some(chat) = chat_block.as_mut() {
1261            match event {
1262                Event::Text(text) => chat.source.push_str(&text),
1263                Event::End(TagEnd::CodeBlock) => {
1264                    if let Some(chat) = chat_block.take() {
1265                        push_chat_block(&mut events, text_lines, block_lines, &chat);
1266                    }
1267                }
1268                // A code block holds nothing but text; nothing else can arrive.
1269                _ => {}
1270            }
1271            continue;
1272        }
1273        if let Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) = &event
1274            && crate::chat::is_chat_info(info)
1275        {
1276            let fence_line = (text_lines.enabled || block_lines.enabled).then(|| {
1277                line_index
1278                    .get_or_insert_with(|| LineIndex::build(markdown_input))
1279                    .line_of(range.start)
1280            });
1281            chat_block = Some(PendingChat {
1282                source: String::new(),
1283                fence_line,
1284            });
1285            continue;
1286        }
1287
1288        match &event {
1289            // --- Heading extraction ---
1290            Event::Start(Tag::Heading { .. }) => {
1291                in_heading_text = Some(String::new());
1292                push_event(&mut events, text_lines, block_lines, event, source_line);
1293            }
1294            // Heading label accumulation. `Event::Code` (inline code spans) and
1295            // `Event::InlineMath` carry visible heading text and must be
1296            // included, otherwise "The `main` function" yields the label
1297            // "The  function" and the anchor id `the--function`.
1298            //
1299            // Deliberately NOT accumulated: `Event::InlineHtml` (its payload is
1300            // raw markup like `<kbd>`, whose inner text already arrives as a
1301            // separate `Event::Text`) and `Event::FootnoteReference` (the label
1302            // is a citation marker, not part of the heading's name).
1303            Event::Text(text) | Event::Code(text) | Event::InlineMath(text)
1304                if in_heading_text.is_some() =>
1305            {
1306                if let Some(ref mut heading_text) = in_heading_text {
1307                    heading_text.push_str(text);
1308                }
1309                // `source_line` is `None` for the `Code` / `InlineMath` halves
1310                // of this pattern, so only the `Text` half is recorded.
1311                push_event(&mut events, text_lines, block_lines, event, source_line);
1312            }
1313
1314            // --- remark-hint syntax detection (inline) ---
1315            // A paragraph whose first text run starts with `!> `/`?> `/`x> ` becomes the
1316            // matching GitHub-style alert blockquote (Tip/Warning/Caution).
1317            Event::Text(text) if matches!(events.last(), Some(Event::Start(Tag::Paragraph))) => {
1318                if let Some((kind, rest)) = detect_hint_prefix(text) {
1319                    // The `Start(Paragraph)` being replaced *is* recorded (under
1320                    // `ReviewLines::Emit`), so read its line before `pop_event`
1321                    // truncates the record, and give it to both synthesized
1322                    // block starts. `source_line` cannot be used for them: it is
1323                    // this `Text` event's line, computed only when `TextLines`
1324                    // is on, so under `--no-mark-incomplete` it is `None` and
1325                    // the alert would lose `data-mbr-line`. The hint prefix
1326                    // opens the paragraph, so both lines are the same anyway.
1327                    let block_line = block_lines.line_recorded_at(events.len() - 1);
1328                    pop_event(&mut events, text_lines, block_lines); // remove the Start(Paragraph)
1329                    push_event(
1330                        &mut events,
1331                        text_lines,
1332                        block_lines,
1333                        Event::Start(Tag::BlockQuote(Some(kind))),
1334                        block_line,
1335                    );
1336                    push_event(
1337                        &mut events,
1338                        text_lines,
1339                        block_lines,
1340                        Event::Start(Tag::Paragraph),
1341                        block_line,
1342                    );
1343                    // The hint prefix is stripped from the front of the same
1344                    // run, so the remainder is still on the run's line.
1345                    push_event(
1346                        &mut events,
1347                        text_lines,
1348                        block_lines,
1349                        Event::Text(CowStr::from(rest.to_owned())),
1350                        source_line,
1351                    );
1352                    hint_open = true;
1353                    continue;
1354                }
1355                push_event(&mut events, text_lines, block_lines, event, source_line);
1356            }
1357            Event::End(TagEnd::Heading(heading_level)) => {
1358                if let Some(text) = in_heading_text.take() {
1359                    let id = generate_anchor_id(&text, &mut anchor_ids);
1360                    let level_num = match heading_level {
1361                        HeadingLevel::H1 => 1,
1362                        HeadingLevel::H2 => 2,
1363                        HeadingLevel::H3 => 3,
1364                        HeadingLevel::H4 => 4,
1365                        HeadingLevel::H5 => 5,
1366                        HeadingLevel::H6 => 6,
1367                    };
1368
1369                    headings.push(HeadingInfo {
1370                        level: level_num,
1371                        text: text.clone(),
1372                        id: id.clone(),
1373                    });
1374
1375                    // Walk backward to find the matching Start(Heading) and inject the ID
1376                    for i in (0..events.len()).rev() {
1377                        if let Event::Start(Tag::Heading {
1378                            level,
1379                            id: _,
1380                            classes,
1381                            attrs,
1382                        }) = &events[i]
1383                        {
1384                            events[i] = Event::Start(Tag::Heading {
1385                                level: *level,
1386                                id: Some(CowStr::from(id)),
1387                                classes: classes.clone(),
1388                                attrs: attrs.clone(),
1389                            });
1390                            break;
1391                        }
1392                    }
1393                }
1394                push_event(&mut events, text_lines, block_lines, event, source_line);
1395            }
1396
1397            // --- Rule attrs detection (inline) ---
1398            // Detect End(Paragraph) and look back for the 3-event pattern:
1399            //   Start(Paragraph), Text("em-dash + {attrs}"), End(Paragraph)
1400            Event::End(TagEnd::Paragraph) => {
1401                // Close an open remark-hint alert: emit the paragraph end followed by
1402                // the blockquote end. A hint paragraph never matches the em-dash rule
1403                // pattern, so handling it first is safe.
1404                if hint_open {
1405                    push_event(&mut events, text_lines, block_lines, event, source_line);
1406                    push_event(
1407                        &mut events,
1408                        text_lines,
1409                        block_lines,
1410                        Event::End(TagEnd::BlockQuote(None)),
1411                        source_line,
1412                    );
1413                    hint_open = false;
1414                    continue;
1415                }
1416
1417                let len = events.len();
1418                // Need at least 2 prior events to form the pattern
1419                if len >= 2 {
1420                    let rule_attrs = match (&events[len - 2], &events[len - 1]) {
1421                        (Event::Start(Tag::Paragraph), Event::Text(text)) => {
1422                            section_rule_attrs(text)
1423                        }
1424                        _ => None,
1425                    };
1426
1427                    if let Some(attrs) = rule_attrs {
1428                        // Extract and parse attrs from the text event
1429                        let parsed = ParsedAttrs::parse(attrs);
1430
1431                        // Remove the Start(Paragraph) and Text events. The Text
1432                        // is recorded, and the `Rule` pushed below reuses its
1433                        // slot, so the table has to shrink with the vector.
1434                        pop_event(&mut events, text_lines, block_lines); // Text
1435                        pop_event(&mut events, text_lines, block_lines); // Start(Paragraph)
1436
1437                        // Emit a Rule event instead
1438                        push_event(
1439                            &mut events,
1440                            text_lines,
1441                            block_lines,
1442                            Event::Rule,
1443                            source_line,
1444                        );
1445                        section_index += 1;
1446
1447                        if let Some(attrs) = parsed {
1448                            section_attrs.insert(section_index, attrs);
1449                        }
1450                        // Skip pushing the End(Paragraph) event
1451                        continue;
1452                    }
1453                }
1454                push_event(&mut events, text_lines, block_lines, event, source_line);
1455            }
1456
1457            // Track real Rule events for section counting
1458            Event::Rule => {
1459                section_index += 1;
1460                push_event(&mut events, text_lines, block_lines, event, source_line);
1461            }
1462
1463            _ => {
1464                push_event(&mut events, text_lines, block_lines, event, source_line);
1465            }
1466        }
1467    }
1468
1469    // A document that ends mid-item still has to close its span.
1470    if let Some(task) = pending_task.take() {
1471        close_task(&mut events, task);
1472    }
1473
1474    (events, headings, section_attrs)
1475}
1476
1477/// A ```` ```chat ```` fence whose text is still being collected.
1478struct PendingChat {
1479    source: String,
1480    /// The fence's 1-based source line; `None` when neither line table is
1481    /// recording, so a page nobody anchors never builds a [`LineIndex`].
1482    fence_line: Option<u32>,
1483}
1484
1485/// Replaces a ```` ```chat ```` code block with bubble markup whose bodies are
1486/// **real markdown events**, spliced into the page's own stream.
1487///
1488/// # Why here and not in `html.rs`
1489///
1490/// A writer-side sub-render would be simpler, but it would see bodies after
1491/// [`process_all_events`] has run, so a relative link inside a bubble would
1492/// miss the trailing-slash link transform, `[[wikilinks]]` would not resolve,
1493/// images would not become media embeds, and backlinks / `errors.json` would
1494/// not see the links at all. Splicing events here sends bodies through every
1495/// later pass for free. Pass 1 is also the only place source lines exist.
1496///
1497/// # Invariants this must keep
1498///
1499/// * Every push goes through [`push_event`], with the body's real source line
1500///   (fence line + 1 + the body's line within the block), so [`TextLines`] and
1501///   [`BlockLines`] stay strictly ascending and point where a reader expects.
1502///   Content lines map 1:1 to source lines even inside a list item or
1503///   blockquote: the parser strips the container prefix but never a newline.
1504/// * Body events bypass pass 1's own arms. Body headings therefore get no
1505///   generated id and stay out of the page TOC (a bubble is not a section of
1506///   the document); `[ ]` stays a plain disabled checkbox (the task index skips
1507///   code fences, so wiring it would advertise a task nothing can find).
1508/// * `Event::Rule` becomes a literal `<hr />`: `html.rs` turns a top-level
1509///   rule into a `</section><section>` split — which would close the section
1510///   from inside the bubble's `<div>` — and both sides count rules to number
1511///   `--- {attrs}` sections, so a body rule would shift every later section's
1512///   attributes by one.
1513/// * Metadata blocks are disabled for bodies, so a body starting with `---`
1514///   is a rule rather than a second frontmatter block.
1515///
1516/// Wikilinks: plain `[[Note]]` and `[[Tags:x]]` both arrive as
1517/// `LinkType::WikiLink` from pulldown-cmark itself and are resolved in
1518/// `process_event`, so no pre-pass is needed. The `transform_wikilinks`
1519/// pre-pass skips fences (and so these bodies); what it adds over the native
1520/// path is only `[[Source:value|label]]` label handling.
1521fn push_chat_block<'a>(
1522    events: &mut Vec<Event<'a>>,
1523    text_lines: &mut TextLines,
1524    block_lines: &mut BlockLines,
1525    chat: &PendingChat,
1526) {
1527    use crate::chat::{self, ChatItem};
1528
1529    // The fence's content starts on the line after the fence.
1530    let content_line = chat.fence_line.map(|line| line.saturating_add(1));
1531    // Only the review feature reads the container's `data-mbr-line`; it would
1532    // otherwise be noise — and under `TermsOnly` it would number a non-term.
1533    let review_line = chat
1534        .fence_line
1535        .filter(|_| block_lines.numbers_every_block());
1536    let source_index = content_line.map(|_| LineIndex::build(&chat.source));
1537    // 1-based source line of byte `offset` in the block's text.
1538    let line_at = |offset: usize| -> Option<u32> {
1539        let base = content_line?;
1540        let within = source_index.as_ref()?.line_of(offset);
1541        Some(base.saturating_add(within - 1))
1542    };
1543    let html = |events: &mut Vec<Event<'a>>,
1544                text_lines: &mut TextLines,
1545                block_lines: &mut BlockLines,
1546                html: String| {
1547        push_event(
1548            events,
1549            text_lines,
1550            block_lines,
1551            Event::Html(CowStr::from(html)),
1552            None,
1553        );
1554    };
1555
1556    html(
1557        events,
1558        text_lines,
1559        block_lines,
1560        chat::open_html(review_line),
1561    );
1562    for item in chat::parse(&chat.source) {
1563        let text_is_comment = matches!(item, ChatItem::Comment { .. });
1564        match item {
1565            ChatItem::Message(message) => {
1566                html(
1567                    events,
1568                    text_lines,
1569                    block_lines,
1570                    chat::message_open_html(&message),
1571                );
1572                push_chat_markdown(
1573                    events,
1574                    text_lines,
1575                    block_lines,
1576                    &message.body,
1577                    line_at(message.body_offset),
1578                );
1579                html(
1580                    events,
1581                    text_lines,
1582                    block_lines,
1583                    chat::message_close_html(&message),
1584                );
1585            }
1586            ChatItem::Delimiter => html(
1587                events,
1588                text_lines,
1589                block_lines,
1590                chat::DELIMITER_HTML.to_string(),
1591            ),
1592            ChatItem::Comment { text, offset } | ChatItem::Markdown { text, offset } => {
1593                let open = if text_is_comment {
1594                    chat::COMMENT_OPEN_HTML
1595                } else {
1596                    chat::MARKDOWN_OPEN_HTML
1597                };
1598                html(events, text_lines, block_lines, open.to_string());
1599                push_chat_markdown(events, text_lines, block_lines, &text, line_at(offset));
1600                html(
1601                    events,
1602                    text_lines,
1603                    block_lines,
1604                    chat::CLOSE_HTML.to_string(),
1605                );
1606            }
1607        }
1608    }
1609    html(
1610        events,
1611        text_lines,
1612        block_lines,
1613        chat::CLOSE_HTML.to_string(),
1614    );
1615}
1616
1617/// Parses one chat body (or the markdown between bubbles) and pushes its events.
1618///
1619/// `first_line` is the source line of `text`'s first byte, `None` when no line
1620/// table is recording.
1621fn push_chat_markdown<'a>(
1622    events: &mut Vec<Event<'a>>,
1623    text_lines: &mut TextLines,
1624    block_lines: &mut BlockLines,
1625    text: &str,
1626    first_line: Option<u32>,
1627) {
1628    let options = markdown_options()
1629        - Options::ENABLE_YAML_STYLE_METADATA_BLOCKS
1630        - Options::ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS;
1631    let index = first_line.map(|_| LineIndex::build(text));
1632    let parser = TextMergeWithOffset::new(MDParser::new_ext(text, options).into_offset_iter());
1633    for (event, range) in parser {
1634        let wants_line =
1635            (text_lines.enabled && matches!(event, Event::Text(_))) || block_lines.wants(&event);
1636        let line = match (first_line, &index) {
1637            (Some(base), Some(index)) if wants_line => {
1638                Some(base.saturating_add(index.line_of(range.start) - 1))
1639            }
1640            _ => None,
1641        };
1642        let event = match event {
1643            Event::Rule => Event::Html(CowStr::Borrowed("<hr />\n")),
1644            // The body is a temporary; the page's events outlive it.
1645            other => other.into_static(),
1646        };
1647        push_event(events, text_lines, block_lines, event, line);
1648    }
1649}
1650
1651/// A task item whose checkbox has been emitted and whose text span is still open.
1652struct PendingTask {
1653    /// Positions in the output of the text runs making up the display text.
1654    ///
1655    /// Collected rather than stripped as they arrive: the annotation grammar
1656    /// has end-anchored rules (the trailing `> YYYY-MM-DD`, the whitespace
1657    /// collapse), so no run can be rewritten until the last one has been seen.
1658    text_at: Vec<usize>,
1659}
1660
1661/// Closes an open task: strips the annotations out of its text runs, rewrites
1662/// them in place, and appends the closing span plus the annotation chips.
1663///
1664/// # Why this may bypass `push_event`
1665///
1666/// The only events it appends are `Event::Html`, which is neither an
1667/// `Event::Text` nor a block start, so it owes neither [`TextLines`] nor
1668/// [`BlockLines`] a record. Anything block-shaped added here in future must go
1669/// through [`push_event`] instead, or the `data-mbr-line` table will address the
1670/// wrong slots from that point on.
1671fn close_task(output: &mut Vec<Event<'_>>, task: PendingTask) {
1672    let (stripped, annotations) = {
1673        let runs: Vec<&str> = task
1674            .text_at
1675            .iter()
1676            .map(|&index| match &output[index] {
1677                Event::Text(text) => text.as_ref(),
1678                // Only text events are recorded, so this is unreachable.
1679                _ => "",
1680            })
1681            .collect();
1682        tasks::strip_annotations_across_runs(&runs)
1683    };
1684
1685    for (&index, text) in task.text_at.iter().zip(stripped) {
1686        output[index] = Event::Text(CowStr::from(text));
1687    }
1688
1689    output.push(Event::Html(CowStr::from(crate::html::TASK_TEXT_CLOSE)));
1690    let chips = crate::html::task_annotations_html(&annotations);
1691    if !chips.is_empty() {
1692        output.push(Event::Html(CowStr::from(chips)));
1693    }
1694}
1695
1696/// Everything a full render needs besides the markdown text itself.
1697///
1698/// Shared by [`render_str`], [`render_sync`] and [`render_with_cache`], which
1699/// differ only in where the text comes from and in whether bare URLs may be
1700/// fetched over the network (see [`render_with_cache`]).
1701pub struct RenderOptions<'a> {
1702    /// Repository root, for resolving links and media.
1703    pub root_path: &'a Path,
1704    /// OpenGraph enrichment timeout. `0` disables it: no fetches and no
1705    /// cached network results. No-network embeds are produced regardless.
1706    pub oembed_timeout_ms: u64,
1707    pub link_transform_config: LinkTransformConfig,
1708    /// Shared OpenGraph cache: read by every entry point, written only by the
1709    /// async one, which is the only one that fetches.
1710    pub oembed_cache: Option<Arc<OembedCache>>,
1711    /// True in server/GUI mode, false in build/CLI mode.
1712    pub server_mode: bool,
1713    /// True when dynamic video transcoding is enabled.
1714    pub transcode_enabled: bool,
1715    /// Valid tag source names for `[[Source:value]]` wikilink transformation.
1716    pub valid_tag_sources: HashSet<String>,
1717    /// Whether to emit `data-mbr-line` on block elements.
1718    pub review: ReviewLines,
1719    /// Whether to highlight TK/TODO/FIXME/XXX (pass 3).
1720    pub mark_incomplete: bool,
1721    pub incomplete_markers: &'a [String],
1722    pub wikilink_index: Option<Arc<WikilinkIndex>>,
1723}
1724
1725/// Rewrites the URL-valued Reveal.js background attributes of every
1726/// `--- {attrs}` section through [`transform_link`], exactly as an image's
1727/// `dest_url` is rewritten in pass 2.
1728///
1729/// Pages are served at trailing-slash URLs (`deck.md` → `/deck/`), so an
1730/// authored `data-background-image="images/x.png"` left verbatim resolves to
1731/// `/deck/images/x.png` — a 404 — while `![](images/x.png)` on the same page
1732/// works. Running the image transform keeps the two in agreement in server,
1733/// static-build (`url_depth`) and QuickLook rendering alike.
1734///
1735/// - `data-background-image`, `data-background-iframe`: one URL.
1736/// - `data-background-video`: a comma-separated list, each item rewritten.
1737/// - `data-background`: Reveal's shorthand, which is a URL only when Reveal
1738///   itself would treat it as one ([`is_reveal_background_url`]); a colour or
1739///   gradient is left alone.
1740///
1741/// External, root-relative and anchor values are untouched because
1742/// [`transform_link`] leaves them untouched.
1743fn transform_section_background_urls(
1744    section_attrs: HashMap<usize, ParsedAttrs>,
1745    config: &LinkTransformConfig,
1746) -> HashMap<usize, ParsedAttrs> {
1747    section_attrs
1748        .into_iter()
1749        .map(|(index, attrs)| {
1750            let rewritten = attrs
1751                .attrs
1752                .into_iter()
1753                .map(|(key, value)| {
1754                    let value = value.map(|v| transform_background_attr(&key, v, config));
1755                    (key, value)
1756                })
1757                .collect();
1758            (
1759                index,
1760                ParsedAttrs {
1761                    attrs: rewritten,
1762                    ..attrs
1763                },
1764            )
1765        })
1766        .collect()
1767}
1768
1769/// The rewrite for one section attribute; anything not a background URL is
1770/// returned as is.
1771fn transform_background_attr(key: &str, value: String, config: &LinkTransformConfig) -> String {
1772    match key.to_ascii_lowercase().as_str() {
1773        "data-background-image" | "data-background-iframe" => transform_link(&value, config),
1774        // A `data:` URL carries its own commas; splitting it would mangle the
1775        // payload (`data:…;base64,../AAAA`).
1776        "data-background-video"
1777            if value
1778                .trim_start()
1779                .get(..5)
1780                .is_some_and(|s| s.eq_ignore_ascii_case("data:")) =>
1781        {
1782            value
1783        }
1784        "data-background-video" => value
1785            .split(',')
1786            .map(|item| {
1787                let trimmed = item.trim();
1788                if trimmed.is_empty() {
1789                    item.to_string()
1790                } else {
1791                    // Keep the author's spacing around each comma.
1792                    let start = item.len() - item.trim_start().len();
1793                    let end = start + trimmed.len();
1794                    format!(
1795                        "{}{}{}",
1796                        &item[..start],
1797                        transform_link(trimmed, config),
1798                        &item[end..]
1799                    )
1800                }
1801            })
1802            .collect::<Vec<_>>()
1803            .join(","),
1804        "data-background" if is_reveal_background_url(&value) => transform_link(&value, config),
1805        _ => value,
1806    }
1807}
1808
1809/// Whether Reveal.js treats a `data-background` shorthand value as an image
1810/// URL rather than a CSS background.
1811///
1812/// Mirrors Reveal's own test (`backgrounds.js`): a value starting with
1813/// `http`, `file` or `//`, or containing an image extension followed by
1814/// `?`, `#`, whitespace or the end. Anything else (`#336`, `red`,
1815/// `rgb(…)`, `linear-gradient(…)`) is assigned to `style.background` by
1816/// Reveal and must not be rewritten. Case-insensitive, like Reveal's `/gi`.
1817pub(crate) fn is_reveal_background_url(value: &str) -> bool {
1818    const IMAGE_EXTENSIONS: [&str; 7] = ["svg", "png", "jpg", "jpeg", "gif", "bmp", "webp"];
1819    let lower = value.to_ascii_lowercase();
1820    if ["http", "file", "//"].iter().any(|p| lower.starts_with(p)) {
1821        return true;
1822    }
1823    lower.match_indices('.').any(|(dot, _)| {
1824        let rest = &lower[dot + 1..];
1825        IMAGE_EXTENSIONS.iter().any(|ext| {
1826            rest.strip_prefix(ext).is_some_and(|after| {
1827                after
1828                    .chars()
1829                    .next()
1830                    .is_none_or(|c| c == '?' || c == '#' || c.is_whitespace())
1831            })
1832        })
1833    })
1834}
1835
1836/// A quoted `src`, `srcset`, `href` or `poster` attribute in raw HTML. The
1837/// name must stand alone — preceded by whitespace or the start of the chunk —
1838/// so `data-src=`, `data-srcset=` and `xlink:href=` do not match. Group 1 is
1839/// everything up to the opening quote, group 2 the attribute name, group 3 a
1840/// double-quoted value, group 4 a single-quoted one.
1841static HTML_URL_ATTR: LazyLock<Regex> = LazyLock::new(|| {
1842    Regex::new(r#"(?i)((?:^|\s)(srcset|src|href|poster)\s*=\s*)(?:"([^"]*)"|'([^']*)')"#)
1843        .expect("HTML_URL_ATTR is a valid regex")
1844});
1845
1846/// Whether `html` names any attribute [`HTML_URL_ATTR`] can match, ASCII
1847/// case-insensitively. A byte scan, so the common chunk (`<div>`, `</p>`)
1848/// never reaches the regex and nothing is allocated. `srcset` needs no entry
1849/// of its own: it contains `src`.
1850fn mentions_url_attr(html: &str) -> bool {
1851    const NAMES: [&[u8]; 3] = [b"src", b"href", b"poster"];
1852    let bytes = html.as_bytes();
1853    NAMES.iter().any(|name| {
1854        bytes
1855            .windows(name.len())
1856            .any(|window| window.eq_ignore_ascii_case(name))
1857    })
1858}
1859
1860/// Rewrites the relative URLs of quoted `src`, `srcset`, `href` and `poster`
1861/// attributes in a chunk of raw HTML through [`transform_link`], exactly as
1862/// pass 2 rewrites a markdown image or link — the raw-HTML counterpart of
1863/// [`transform_section_background_urls`]. A `srcset` is a list of candidates
1864/// (`a.png 1x, b.png 2x`): each candidate's URL is transformed on its own and
1865/// its descriptor and separators kept (see [`url_path::rewrite_srcset`]), so a
1866/// `<picture>`'s dark-mode `<source>` resolves like its `<img>`.
1867///
1868/// Pages are served at trailing-slash URLs (`README.md` → `/README/`), so a
1869/// README's `<img src="docs/images/x.png">` left verbatim resolves to
1870/// `/README/docs/images/x.png`, while `![](docs/images/x.png)` beside it
1871/// works. Values are HTML: entities are decoded before the transform and the
1872/// result is re-escaped for its quote. External, root-relative (server mode)
1873/// and anchor values are left alone by [`transform_link`], and a value it
1874/// does not change keeps its authored bytes; with nothing changed the chunk
1875/// is returned borrowed.
1876///
1877/// Known limits, accepted: an attribute whose name and value sit on different
1878/// lines is not matched (pulldown-cmark hands an HTML block over a line at a
1879/// time); unquoted values are left alone, as are `srcset` URLs containing
1880/// commas (see [`url_path::srcset_url_spans`]); a `<script>`/`<style>`
1881/// body containing `src="…"` is rewritten too (harmless for the absolute URLs
1882/// such bodies normally hold).
1883fn transform_raw_html_urls<'h>(html: &'h str, config: &LinkTransformConfig) -> Cow<'h, str> {
1884    if !mentions_url_attr(html) {
1885        return Cow::Borrowed(html);
1886    }
1887    let mut rewritten: Option<String> = None;
1888    let mut copied_to = 0;
1889    for caps in HTML_URL_ATTR.captures_iter(html) {
1890        let (value, double_quoted) = match (caps.get(3), caps.get(4)) {
1891            (Some(v), _) => (v, true),
1892            (None, Some(v)) => (v, false),
1893            (None, None) => continue,
1894        };
1895        let decoded = html_escape::decode_html_entities(value.as_str());
1896        let is_srcset = caps
1897            .get(2)
1898            .is_some_and(|name| name.as_str().eq_ignore_ascii_case("srcset"));
1899        let transformed = if is_srcset {
1900            match url_path::rewrite_srcset(&decoded, |url| {
1901                let transformed = transform_link(url, config);
1902                (transformed != url).then_some(transformed)
1903            }) {
1904                Cow::Borrowed(_) => continue,
1905                Cow::Owned(rewritten) => rewritten,
1906            }
1907        } else {
1908            let transformed = transform_link(&decoded, config);
1909            if transformed == decoded {
1910                continue;
1911            }
1912            transformed
1913        };
1914        let escaped = if double_quoted {
1915            html_escape::encode_double_quoted_attribute(&transformed)
1916        } else {
1917            html_escape::encode_single_quoted_attribute(&transformed)
1918        };
1919        let out = rewritten.get_or_insert_with(|| String::with_capacity(html.len() + 16));
1920        out.push_str(&html[copied_to..value.start()]);
1921        out.push_str(&escaped);
1922        copied_to = value.end();
1923    }
1924    match rewritten {
1925        Some(mut out) => {
1926            out.push_str(&html[copied_to..]);
1927            Cow::Owned(out)
1928        }
1929        None => Cow::Borrowed(html),
1930    }
1931}
1932
1933/// Pass 1's output plus the state passes 2 and 3 read back.
1934///
1935/// Bundled so the async and sync entry points cannot drift apart between
1936/// passes: everything that differs between them happens between
1937/// [`first_pass`] and [`finish_render`].
1938struct FirstPass<'a> {
1939    events: Vec<Event<'a>>,
1940    headings: Vec<HeadingInfo>,
1941    section_attrs: HashMap<usize, ParsedAttrs>,
1942    marker_rule: Option<Arc<MarkerRule>>,
1943    text_lines: TextLines,
1944    block_lines: BlockLines,
1945}
1946
1947/// Strips a leading BOM and substitutes `[[Source:value]]` tag wikilinks with
1948/// standard markdown links, which must happen before the parser sees the text.
1949fn prepare_source<'s>(source: &'s str, valid_tag_sources: &HashSet<String>) -> Cow<'s, str> {
1950    let source = strip_bom(source);
1951    if valid_tag_sources.is_empty() {
1952        Cow::Borrowed(source)
1953    } else {
1954        Cow::Owned(transform_wikilinks(source, valid_tag_sources))
1955    }
1956}
1957
1958/// Pass 1 of a full render.
1959///
1960/// A single merged pass: collect events, extract headings with anchor IDs,
1961/// detect `--- {attrs}` rule patterns, and rewrite task list items (merging
1962/// what were previously the heading extraction loop, transform_rule_attrs and
1963/// a separate task pass into one iteration). Running the task rewrite here
1964/// rather than after `process_all_events` also means the annotations are gone
1965/// before text is counted for readability and scanned for bare URLs.
1966///
1967/// The marker rule is fetched *before* pass 1 because it decides whether pass 1
1968/// has to record the source line of every text run for pass 3. `cached`, not
1969/// `new`: compiling the pattern costs ~80µs, which is several times a small
1970/// page's entire render, and this runs per page.
1971fn first_pass<'a>(markdown_input: &'a str, opts: &RenderOptions<'_>) -> FirstPass<'a> {
1972    let marker_rule = opts
1973        .mark_incomplete
1974        .then(|| MarkerRule::cached(opts.incomplete_markers))
1975        .flatten();
1976    let mut text_lines = if marker_rule.is_some() {
1977        TextLines::recording()
1978    } else {
1979        TextLines::disabled()
1980    };
1981    // Independent of the marker rule: `review_enabled` and `mark_incomplete` are
1982    // separate switches, which is why these are two tables.
1983    let mut block_lines = BlockLines::for_mode(opts.review);
1984    let (events, headings, section_attrs) = collect_events_and_headings(
1985        markdown_input,
1986        TaskMarkup::Render,
1987        &mut text_lines,
1988        &mut block_lines,
1989    );
1990    FirstPass {
1991        events,
1992        headings,
1993        section_attrs,
1994        marker_rule,
1995        text_lines,
1996        block_lines,
1997    }
1998}
1999
2000/// Passes 2 and 3 of a full render, then HTML generation.
2001///
2002/// `prefetched_oembed` is the only input that differs between the entry
2003/// points; `file` only names the file in diagnostics.
2004fn finish_render(
2005    first: FirstPass<'_>,
2006    markdown_input: &str,
2007    file: &Path,
2008    prefetched_oembed: HashMap<String, PageInfo>,
2009    opts: RenderOptions<'_>,
2010) -> Result<MarkdownRenderResult, MarkdownError> {
2011    let FirstPass {
2012        events,
2013        headings,
2014        section_attrs,
2015        marker_rule,
2016        text_lines,
2017        mut block_lines,
2018    } = first;
2019
2020    // Detect if the first heading is an H1 (used for conditional title rendering in templates)
2021    let has_h1 = headings.first().is_some_and(|h| h.level == 1);
2022
2023    // Pass 2: process events through our custom logic (link transforms, media embeds, etc.)
2024    let (processed_events, state) = process_all_events(
2025        events,
2026        opts.root_path,
2027        file,
2028        opts.link_transform_config,
2029        prefetched_oembed,
2030        opts.server_mode,
2031        opts.transcode_enabled,
2032        opts.valid_tag_sources,
2033        opts.wikilink_index,
2034    );
2035
2036    // Section attributes are parsed in pass 1, before any link context exists;
2037    // their Reveal.js background URLs get the same rewrite as an image source.
2038    let section_attrs =
2039        transform_section_background_urls(section_attrs, &state.link_transform_config);
2040
2041    // Pass 3 (optional): highlight TK/TODO/FIXME/XXX. Off by default in build
2042    // mode. It also re-keys `block_lines` onto its own output; skipping it
2043    // leaves the pass-1 keying, which is correct because passes 1 and 2 are 1:1.
2044    let processed_events = match &marker_rule {
2045        Some(rule) => mark_incomplete_blocks(processed_events, rule, &text_lines, &mut block_lines),
2046        None => processed_events,
2047    };
2048
2049    // Generate HTML output and extract frontmatter
2050    finalize_render(
2051        processed_events,
2052        state,
2053        section_attrs,
2054        markdown_input,
2055        headings,
2056        has_h1,
2057        block_lines,
2058    )
2059}
2060
2061/// Renders markdown text to HTML, synchronously and without network access.
2062///
2063/// The one full-render pipeline: [`render_sync`] is this plus a file read, and
2064/// [`render_with_cache`] shares every pass and differs only in fetching
2065/// OpenGraph metadata over the network.
2066///
2067/// No-network embeds (YouTube, Giphy, GitHub gist, and bare-URL media) ARE
2068/// produced — they are pure CPU (regex/string) and require no I/O, so they
2069/// work regardless of `oembed_timeout_ms`. OpenGraph results are never fetched
2070/// here; when `oembed_timeout_ms > 0` and a cache is present, previously cached
2071/// network results are merged in.
2072///
2073/// `file` only names the source in diagnostics (e.g. a YAML frontmatter parse
2074/// warning); it is never read.
2075pub fn render_str(
2076    source: &str,
2077    file: &Path,
2078    opts: RenderOptions<'_>,
2079) -> Result<MarkdownRenderResult, MarkdownError> {
2080    let markdown_input = prepare_source(source, &opts.valid_tag_sources);
2081    let first = first_pass(&markdown_input, &opts);
2082
2083    let mut prefetched_oembed = collect_local_embeds(&first.events);
2084    if opts.oembed_timeout_ms > 0
2085        && let Some(ref cache) = opts.oembed_cache
2086    {
2087        for (url, info) in collect_cached_oembed(&first.events, cache) {
2088            prefetched_oembed.entry(url).or_insert(info);
2089        }
2090    }
2091
2092    finish_render(first, &markdown_input, file, prefetched_oembed, opts)
2093}
2094
2095#[allow(clippy::too_many_arguments)]
2096pub async fn render(
2097    file: PathBuf,
2098    root_path: &Path,
2099    oembed_timeout_ms: u64,
2100    link_transform_config: LinkTransformConfig,
2101    server_mode: bool,
2102    transcode_enabled: bool,
2103    valid_tag_sources: HashSet<String>,
2104    review: ReviewLines,
2105    mark_incomplete: bool,
2106    incomplete_markers: &[String],
2107    wikilink_index: Option<Arc<WikilinkIndex>>,
2108) -> Result<MarkdownRenderResult, MarkdownError> {
2109    render_with_cache(
2110        file,
2111        root_path,
2112        oembed_timeout_ms,
2113        link_transform_config,
2114        None,
2115        server_mode,
2116        transcode_enabled,
2117        valid_tag_sources,
2118        review,
2119        mark_incomplete,
2120        incomplete_markers,
2121        wikilink_index,
2122    )
2123    .await
2124}
2125
2126/// Renders a markdown file to HTML with optional OEmbed caching support.
2127///
2128/// Identical to [`render_str`] except that, when `oembed_timeout_ms > 0`, bare
2129/// URLs are enriched with OpenGraph metadata fetched over the network. When
2130/// `oembed_cache` is provided, cached results are used when available and new
2131/// results are cached for future use. URLs are fetched in parallel for
2132/// improved performance when multiple bare URLs are present in the document.
2133///
2134/// See [`RenderOptions`] for the parameters.
2135#[allow(clippy::too_many_arguments)]
2136pub async fn render_with_cache(
2137    file: PathBuf,
2138    root_path: &Path,
2139    oembed_timeout_ms: u64,
2140    link_transform_config: LinkTransformConfig,
2141    oembed_cache: Option<Arc<OembedCache>>,
2142    server_mode: bool,
2143    transcode_enabled: bool,
2144    valid_tag_sources: HashSet<String>,
2145    review: ReviewLines,
2146    mark_incomplete: bool,
2147    incomplete_markers: &[String],
2148    wikilink_index: Option<Arc<WikilinkIndex>>,
2149) -> Result<MarkdownRenderResult, MarkdownError> {
2150    // Read markdown input. Use tokio's async filesystem API so this (potentially
2151    // slow) read does not block a tokio worker thread in the async render path.
2152    let raw_markdown_input =
2153        tokio::fs::read_to_string(&file)
2154            .await
2155            .map_err(|e| MarkdownError::ReadFailed {
2156                path: file.clone(),
2157                source: e,
2158            })?;
2159
2160    let opts = RenderOptions {
2161        root_path,
2162        oembed_timeout_ms,
2163        link_transform_config,
2164        oembed_cache,
2165        server_mode,
2166        transcode_enabled,
2167        valid_tag_sources,
2168        review,
2169        mark_incomplete,
2170        incomplete_markers,
2171        wikilink_index,
2172    };
2173    render_text_with_cache(&raw_markdown_input, &file, opts).await
2174}
2175
2176/// [`render_with_cache`] for a file in a vault: `file` is its index key
2177/// ([`crate::vault::Vault::key`]), read through `vault` on a blocking thread
2178/// (as `tokio::fs` would read it), then rendered identically.
2179#[allow(clippy::too_many_arguments)]
2180pub async fn render_from_vault_with_cache(
2181    vault: Arc<dyn crate::vault::Vault>,
2182    file: PathBuf,
2183    root_path: &Path,
2184    oembed_timeout_ms: u64,
2185    link_transform_config: LinkTransformConfig,
2186    oembed_cache: Option<Arc<OembedCache>>,
2187    server_mode: bool,
2188    transcode_enabled: bool,
2189    valid_tag_sources: HashSet<String>,
2190    review: ReviewLines,
2191    mark_incomplete: bool,
2192    incomplete_markers: &[String],
2193    wikilink_index: Option<Arc<WikilinkIndex>>,
2194) -> Result<MarkdownRenderResult, MarkdownError> {
2195    let raw_markdown_input = read_source_blocking(vault, file.clone()).await?;
2196
2197    let opts = RenderOptions {
2198        root_path,
2199        oembed_timeout_ms,
2200        link_transform_config,
2201        oembed_cache,
2202        server_mode,
2203        transcode_enabled,
2204        valid_tag_sources,
2205        review,
2206        mark_incomplete,
2207        incomplete_markers,
2208        wikilink_index,
2209    };
2210    render_text_with_cache(&raw_markdown_input, &file, opts).await
2211}
2212
2213/// Reads a markdown source through `vault` by its index key `file`.
2214///
2215/// The error names the key, as the file-based readers name the path, and
2216/// keeps the I/O kind ([`crate::vault::VaultError`]'s conversion), so a
2217/// missing file is still `NotFound`.
2218pub fn read_source(vault: &dyn crate::vault::Vault, file: &Path) -> Result<String, MarkdownError> {
2219    vault
2220        .vault_path(file)
2221        .and_then(|path| vault.read_to_string(&path))
2222        .map_err(|e| MarkdownError::ReadFailed {
2223            path: file.to_path_buf(),
2224            source: e.into(),
2225        })
2226}
2227
2228/// [`read_source`] on tokio's blocking pool, which is where `tokio::fs` does
2229/// its reads too.
2230async fn read_source_blocking(
2231    vault: Arc<dyn crate::vault::Vault>,
2232    file: PathBuf,
2233) -> Result<String, MarkdownError> {
2234    let key = file.clone();
2235    tokio::task::spawn_blocking(move || read_source(vault.as_ref(), &key))
2236        .await
2237        .map_err(|e| MarkdownError::ReadFailed {
2238            path: file,
2239            source: std::io::Error::other(e),
2240        })?
2241}
2242
2243/// A markdown source as read for a page render, with its fingerprint.
2244#[derive(Debug, Clone)]
2245pub struct FingerprintedSource {
2246    /// The file's text, exactly as read.
2247    pub text: String,
2248    /// [`crate::edit_auth::content_hash`] of `text`'s bytes: the version of the
2249    /// file the page was rendered from, which the write endpoints compare
2250    /// against (`base_hash`) so a page cannot patch a file it never saw.
2251    pub hash: String,
2252}
2253
2254/// [`read_source`] plus the source's fingerprint, both on tokio's blocking
2255/// pool (hashing a large note is not something to do on an executor thread).
2256///
2257/// For the server's page render, the one render whose output can drive a
2258/// write; every other render skips the hash.
2259pub async fn read_source_fingerprinted(
2260    vault: Arc<dyn crate::vault::Vault>,
2261    file: PathBuf,
2262) -> Result<FingerprintedSource, MarkdownError> {
2263    let key = file.clone();
2264    tokio::task::spawn_blocking(move || {
2265        read_source(vault.as_ref(), &key).map(|text| FingerprintedSource {
2266            hash: crate::edit_auth::content_hash(text.as_bytes()),
2267            text,
2268        })
2269    })
2270    .await
2271    .map_err(|e| MarkdownError::ReadFailed {
2272        path: file,
2273        source: std::io::Error::other(e),
2274    })?
2275}
2276
2277/// The async render of already-read text: [`render_str`] plus network
2278/// OpenGraph enrichment. Shared by [`render_with_cache`] and
2279/// [`render_from_vault_with_cache`], which differ only in where the text
2280/// comes from, and by the server's page render, which reads the text itself
2281/// ([`read_source_fingerprinted`]).
2282pub async fn render_text_with_cache(
2283    raw_markdown_input: &str,
2284    file: &Path,
2285    opts: RenderOptions<'_>,
2286) -> Result<MarkdownRenderResult, MarkdownError> {
2287    let markdown_input = prepare_source(raw_markdown_input, &opts.valid_tag_sources);
2288    let first = first_pass(&markdown_input, &opts);
2289
2290    // No-network embeds (YouTube/Giphy/gist/bare media) are pure CPU and require
2291    // no I/O, so they are produced regardless of `oembed_timeout_ms` — the docs
2292    // promise they keep working when oembed is disabled. Only network OpenGraph
2293    // enrichment is gated by the timeout; `prefetch_oembed_urls` must stay behind
2294    // that gate because calling it at timeout 0 would fill the cache with empty
2295    // `PageInfo`s. This is the only step that differs from `render_str`.
2296    let mut prefetched_oembed = collect_local_embeds(&first.events);
2297    if opts.oembed_timeout_ms > 0 {
2298        for (url, info) in
2299            prefetch_oembed_urls(&first.events, opts.oembed_timeout_ms, &opts.oembed_cache).await
2300        {
2301            prefetched_oembed.entry(url).or_insert(info);
2302        }
2303    }
2304
2305    finish_render(first, &markdown_input, file, prefetched_oembed, opts)
2306}
2307
2308/// Runs process_event over all events, returning the processed events and final state.
2309///
2310/// This is the shared event processing pass used by every full render (through
2311/// `finish_render`) and by `extract_outbound_links_sync`. It handles link transforms, media embeds, YAML frontmatter,
2312/// vid shortcodes, bare URL oembed lookups, and word counting.
2313///
2314/// `file_path` is only used to name the file in diagnostics (e.g. a YAML
2315/// frontmatter parse warning); it is never read from.
2316///
2317/// # Invariant
2318///
2319/// The output has exactly as many events as the input, in the same order —
2320/// [`process_event`] is a 1:1 map. [`TextLines`] depends on it: it records event
2321/// *indices* in pass 1 and reads them back in pass 3, on the far side of this
2322/// function. The `debug_assert_eq!` below is what keeps that from being a
2323/// comment nobody checks.
2324#[allow(clippy::too_many_arguments)]
2325fn process_all_events<'a>(
2326    events: Vec<Event<'a>>,
2327    root_path: &Path,
2328    file_path: &Path,
2329    link_transform_config: LinkTransformConfig,
2330    prefetched_oembed: HashMap<String, PageInfo>,
2331    server_mode: bool,
2332    transcode_enabled: bool,
2333    valid_tag_sources: HashSet<String>,
2334    wikilink_index: Option<Arc<WikilinkIndex>>,
2335) -> (Vec<Event<'a>>, EventState) {
2336    let mut state = EventState {
2337        root_path: root_path.to_path_buf(),
2338        file_path: file_path.to_path_buf(),
2339        current_media: None,
2340        in_metadata: false,
2341        in_link: false,
2342        metadata_source: None,
2343        metadata_parsed: None,
2344        link_transform_config,
2345        wikilink_index,
2346        prefetched_oembed,
2347        server_mode,
2348        transcode_enabled,
2349        collected_links: Vec::new(),
2350        current_link_dest: None,
2351        current_link_text: String::new(),
2352        valid_tag_sources,
2353        word_count: 0,
2354        in_code_block: false,
2355        sentence_count: 0,
2356        syllable_count: 0,
2357        block_needs_sentence_bump: false,
2358        frontmatter_error: None,
2359        ambiguous_wikilinks: Vec::new(),
2360    };
2361    let expected_len = events.len();
2362    let mut processed_events = Vec::with_capacity(expected_len);
2363
2364    for event in events {
2365        let (processed, new_state) = process_event(event, state);
2366        state = new_state;
2367        processed_events.push(processed);
2368    }
2369
2370    debug_assert_eq!(
2371        processed_events.len(),
2372        expected_len,
2373        "process_event must map one event to one event: `TextLines` addresses \
2374         pass-3 events by their pass-1 index"
2375    );
2376
2377    (processed_events, state)
2378}
2379
2380const INCOMPLETE_SPAN_OPEN: &str = "<span class=\"mbr-incomplete\">";
2381const INCOMPLETE_SPAN_CLOSE: &str = "</span>";
2382
2383/// Opening tag for one highlight, carrying the `#mbr-marker-{line}` deep-link
2384/// anchor when this is the first highlight on its source line.
2385///
2386/// The class is written *before* the id so an assertion can match on the class
2387/// prefix alone whether or not an anchor was attached.
2388///
2389/// The id is attached only when `line` is strictly greater than the highest line
2390/// already anchored. A `>` rather than a `!=` makes a duplicate id impossible
2391/// even if block order ever stopped following source order — table cells, which
2392/// share a line by construction, are the case that made this matter. Later
2393/// markers on the same line are still highlighted, just not linkable: duplicate
2394/// ids are invalid HTML and leave `getElementById` free to pick either one.
2395fn open_incomplete_span(line: Option<u32>, highest_anchored_line: &mut u32) -> Event<'static> {
2396    let html = match line {
2397        Some(line) if line > *highest_anchored_line => {
2398            *highest_anchored_line = line;
2399            CowStr::from(format!(
2400                "<span class=\"mbr-incomplete\" id=\"mbr-marker-{line}\">"
2401            ))
2402        }
2403        _ => CowStr::Borrowed(INCOMPLETE_SPAN_OPEN),
2404    };
2405    Event::Html(html)
2406}
2407
2408/// Slices `text` without giving up its borrow when it has one.
2409///
2410/// A `CowStr::Borrowed` run points straight into the parser's source, so the
2411/// slice is free; a `Boxed` run (one `TextMergeWithOffset` had to concatenate,
2412/// which is what smart punctuation produces) and an `Inlined` one (living inside
2413/// the event value) have to be copied. Byte offsets from a regex match over
2414/// valid UTF-8 always land on char boundaries, so the slice cannot panic.
2415fn slice_cow<'a>(text: &CowStr<'a>, range: std::ops::Range<usize>) -> CowStr<'a> {
2416    match text {
2417        CowStr::Borrowed(source) => CowStr::Borrowed(&source[range]),
2418        owned => CowStr::from(owned[range].to_string()),
2419    }
2420}
2421
2422/// Pushes `text`, wrapping each marker at or after `skip_before` in its own
2423/// `<span class="mbr-incomplete">`.
2424///
2425/// `skip_before` is how a block-initial marker avoids being wrapped a second
2426/// time inside the block-wide span that its own presence opened.
2427fn push_marked_text<'a>(
2428    output: &mut Vec<Event<'a>>,
2429    text: CowStr<'a>,
2430    rule: &MarkerRule,
2431    skip_before: usize,
2432    line: Option<u32>,
2433    highest_anchored_line: &mut u32,
2434) {
2435    // `find_iter` yields ascending, non-overlapping ranges. Collecting an empty
2436    // iterator does not allocate, so the fast path below stays free.
2437    let found: Vec<std::ops::Range<usize>> = rule
2438        .find_iter(&text)
2439        .filter(|range| range.start >= skip_before)
2440        .collect();
2441
2442    // Fast path, and essentially all text: nothing matched, so the run goes
2443    // through untouched — no clone, no allocation, no re-escaping.
2444    if found.is_empty() {
2445        output.push(Event::Text(text));
2446        return;
2447    }
2448
2449    let mut at = 0usize;
2450    for range in found {
2451        if range.start > at {
2452            output.push(Event::Text(slice_cow(&text, at..range.start)));
2453        }
2454        output.push(open_incomplete_span(line, highest_anchored_line));
2455        at = range.end;
2456        output.push(Event::Text(slice_cow(&text, range)));
2457        output.push(Event::Html(CowStr::Borrowed(INCOMPLETE_SPAN_CLOSE)));
2458    }
2459    if at < text.len() {
2460        output.push(Event::Text(slice_cow(&text, at..text.len())));
2461    }
2462}
2463
2464/// Highlights markers (TK/TODO/FIXME/XXX, or whatever `incomplete_markers`
2465/// configures) in `<span class="mbr-incomplete">…</span>`, and gives the first
2466/// highlight on each source line a `#mbr-marker-{line}` anchor.
2467///
2468/// Two shapes of highlight, deliberately:
2469///
2470/// * A block whose **first** text starts with a marker has its whole inline
2471///   content wrapped, so `TK rewrite this paragraph.` washes the paragraph.
2472///   Eligible (innermost) blocks: `Paragraph`, `Heading{..}`, `Item`,
2473///   `TableCell`. Other container tags (`BlockQuote`, `List`, `Table`, code
2474///   blocks, …) are skipped — their inner `Paragraph`, or its absence, is what
2475///   gets evaluated.
2476/// * Every **other** occurrence has just the marker word wrapped, so
2477///   `The market fell 10% (source: TK).` highlights the `TK` and nothing else.
2478///
2479/// What counts as a marker lives in [`MarkerRule`], shared with the task
2480/// scanner so that a highlighted block and an indexed marker cannot disagree.
2481///
2482/// # Two things this pass must not touch
2483///
2484/// * **Code blocks.** A fence or an indented block *inside a list item* leaves
2485///   the `Item` frame open with no text seen yet, so before `code_depth` existed
2486///   `-␣␣␣␣␣TK not a task` emitted `<li><span class="mbr-incomplete"><pre>…`.
2487///   The old `test_incomplete_negative_code_block` missed it by only covering a
2488///   top-level fence. Matching anywhere in the line would have widened that from
2489///   one wrong wrap to every marker in every fenced block in every list item —
2490///   and each would have been attributed to the wrong line, because code-block
2491///   text is the one run that spans source newlines (see [`TextLines`]).
2492/// * **Image alt text.** `html::raw_text` has an `Html(_) => {}` arm: it
2493///   silently drops any injected `Event::Html`. A span emitted inside alt text
2494///   would vanish from the output *and* consume that line's one permitted
2495///   anchor id on the way out.
2496///
2497/// # Index alignment
2498///
2499/// This is the **only** place in the codebase where an index table has to cross
2500/// to the *output* side of a pass that is not 1:1, and the reason is one line of
2501/// code: everything here is `output.push`, except the single
2502/// `output.insert(top.start_idx + 1, …)` that puts a block-wide wash span behind
2503/// an already-emitted `Start`. That insert shifts every output index recorded
2504/// after `top.start_idx`, and it fires in the **default** configuration:
2505///
2506/// ```markdown
2507/// - - inner
2508///   TODO outer text
2509/// ```
2510///
2511/// The inner `<li>` opens *and closes* before the outer item sees any text, so
2512/// the span lands at `outer.start_idx + 1` — behind the inner item's already
2513/// recorded output index — and that record, plus every block after it, needs a
2514/// `+1`.
2515///
2516/// [`BlockLines`] therefore cannot simply be carried through. It is **rebuilt**:
2517/// `block_cursor` reads the incoming (pass-1-keyed) table, `remapped` is written
2518/// with `output.len()` as the key, and `remapped.shift_from` repairs it at the
2519/// insert. `output.len()` is exactly the slot the event is about to occupy —
2520/// every block `Start` reaches an arm that ends in `output.push`, and the frame
2521/// arms already compute `let start_idx = output.len();` immediately before
2522/// pushing. The recording sits at the very top of the loop body, ahead of every
2523/// `continue`; a block start is never an `Event::Text`, so the early text path
2524/// can never reach it.
2525///
2526/// [`TextLines`] needs none of this: it is read on the *input* side, by the
2527/// `enumerate()` counter.
2528fn mark_incomplete_blocks<'a>(
2529    events: Vec<Event<'a>>,
2530    rule: &MarkerRule,
2531    text_lines: &TextLines,
2532    block_lines: &mut BlockLines,
2533) -> Vec<Event<'a>> {
2534    struct Frame {
2535        start_idx: usize,
2536        has_seen_text: bool,
2537        marker_open: bool,
2538    }
2539
2540    let mut output: Vec<Event<'a>> = Vec::with_capacity(events.len());
2541    let mut stack: Vec<Frame> = Vec::new();
2542    let mut cursor = text_lines.cursor();
2543    // The block table is rebuilt against `output` rather than carried; see
2544    // `# Index alignment`. `block_cursor` reads the incoming pass-1 keying,
2545    // `remapped` collects the output keying.
2546    let mut block_cursor = block_lines.cursor();
2547    let mut remapped = block_lines.empty_like();
2548    // Depths rather than booleans: `![a ![b](c) d](e)` nests, and a `<pre>`
2549    // never should but costs nothing to survive.
2550    let mut code_depth: usize = 0;
2551    let mut image_depth: usize = 0;
2552    // Zero, so the first line of the document can still claim an anchor.
2553    let mut highest_anchored_line: u32 = 0;
2554
2555    // The index is the *input* position, which is what `TextLines` recorded;
2556    // `output` drifts from it as spans are inserted.
2557    for (index, event) in events.into_iter().enumerate() {
2558        // Re-key this event's `data-mbr-line` record onto the slot it is about
2559        // to occupy in `output`. Must stay at the very top of the body: every
2560        // path below either pushes the event or `continue`s past it, and the
2561        // text path continues. See `# Index alignment`.
2562        if block_lines.enabled
2563            && let Some(line) = block_cursor.line_at(index)
2564        {
2565            remapped.record(output.len(), Some(line));
2566        }
2567
2568        // Text is split out ahead of the dispatch below so this arm can take
2569        // ownership of the `CowStr` and hand its borrow on to the slices.
2570        let event = match event {
2571            Event::Text(text) => {
2572                // Outside every eligible block there is nothing to wrap and
2573                // nowhere to hang the wrap from: frontmatter and raw HTML
2574                // blocks both land here.
2575                if code_depth > 0 || image_depth > 0 || stack.is_empty() {
2576                    output.push(Event::Text(text));
2577                    continue;
2578                }
2579                let line = cursor.line_at(index);
2580
2581                // A block-initial marker keeps the historical whole-block wash.
2582                let mut skip_before = 0;
2583                if let Some(top) = stack.last_mut()
2584                    && !top.has_seen_text
2585                {
2586                    top.has_seen_text = true;
2587                    let indent = text.len() - text.trim_start().len();
2588                    if let Some(end) = rule.block_initial_match(text.trim_start()) {
2589                        // Immediately after this frame's Start event.
2590                        output.insert(
2591                            top.start_idx + 1,
2592                            open_incomplete_span(line, &mut highest_anchored_line),
2593                        );
2594                        // The one non-`push` in this function, and therefore the
2595                        // one place the output keying has to be repaired. See
2596                        // `# Index alignment`.
2597                        if block_lines.enabled {
2598                            remapped.shift_from(top.start_idx + 1);
2599                        }
2600                        top.marker_open = true;
2601                        // The block-wide span already covers this occurrence.
2602                        skip_before = indent + end;
2603                    }
2604                }
2605
2606                push_marked_text(
2607                    &mut output,
2608                    text,
2609                    rule,
2610                    skip_before,
2611                    line,
2612                    &mut highest_anchored_line,
2613                );
2614                continue;
2615            }
2616            other => other,
2617        };
2618
2619        match &event {
2620            Event::Start(Tag::CodeBlock(_)) => {
2621                code_depth += 1;
2622                output.push(event);
2623            }
2624            Event::End(TagEnd::CodeBlock) => {
2625                code_depth = code_depth.saturating_sub(1);
2626                output.push(event);
2627            }
2628            // A media embed (`![](clip.mp4)`) has already become `Html` by now,
2629            // so this only sees real `<img>`s — the ones whose inner text is
2630            // consumed into an `alt` attribute.
2631            Event::Start(Tag::Image { .. }) => {
2632                image_depth += 1;
2633                output.push(event);
2634            }
2635            Event::End(TagEnd::Image) => {
2636                image_depth = image_depth.saturating_sub(1);
2637                output.push(event);
2638            }
2639            Event::Start(Tag::Paragraph)
2640            | Event::Start(Tag::Heading { .. })
2641            | Event::Start(Tag::Item)
2642            | Event::Start(Tag::TableCell) => {
2643                let start_idx = output.len();
2644                output.push(event);
2645                stack.push(Frame {
2646                    start_idx,
2647                    has_seen_text: false,
2648                    marker_open: false,
2649                });
2650            }
2651            Event::End(TagEnd::Paragraph)
2652            | Event::End(TagEnd::Heading(_))
2653            | Event::End(TagEnd::Item)
2654            | Event::End(TagEnd::TableCell) => {
2655                if let Some(frame) = stack.pop()
2656                    && frame.marker_open
2657                {
2658                    output.push(Event::Html(CowStr::Borrowed(INCOMPLETE_SPAN_CLOSE)));
2659                }
2660                output.push(event);
2661            }
2662            _ => {
2663                output.push(event);
2664            }
2665        }
2666    }
2667
2668    // Hand the output-keyed table back to the caller, which passes it to the
2669    // HTML writer. The pass-1 keying is dead from here on.
2670    *block_lines = remapped;
2671
2672    output
2673}
2674
2675/// Generates final HTML output and constructs the MarkdownRenderResult.
2676///
2677/// Shared finalization logic for both `render_with_cache` and `render_sync`:
2678/// deduplicates outbound links, generates HTML, extracts frontmatter, and
2679/// injects H1 title fallback.
2680///
2681/// `block_lines` is taken by value because the writer owns it from here. When it
2682/// is empty — every caller that did not ask for `data-mbr-line` — the config is
2683/// exactly `HtmlConfig::mbr_with_section_attrs`, so the output is byte-identical
2684/// to what `push_html_mbr_with_attrs` produced before the attribute existed.
2685fn finalize_render(
2686    processed_events: Vec<Event<'_>>,
2687    state: EventState,
2688    section_attrs: HashMap<usize, ParsedAttrs>,
2689    markdown_input: &str,
2690    headings: Vec<HeadingInfo>,
2691    has_h1: bool,
2692    block_lines: BlockLines,
2693) -> Result<MarkdownRenderResult, MarkdownError> {
2694    // Write to a new String buffer with MBR extensions (sections, mermaid)
2695    let mut html_output = String::with_capacity(markdown_input.len() * 2);
2696
2697    // Deduplicate outbound links by target URL - if a page links to the same
2698    // target multiple times, we only keep the first occurrence
2699    let mut seen_targets: HashSet<String> = HashSet::new();
2700    let deduplicated_links: Vec<OutboundLink> = state
2701        .collected_links
2702        .into_iter()
2703        .filter(|link| seen_targets.insert(link.to.clone()))
2704        .collect();
2705
2706    crate::html::push_html_with_config(
2707        &mut html_output,
2708        processed_events.into_iter(),
2709        crate::html::HtmlConfig::mbr_with_attrs_and_block_lines(
2710            section_attrs,
2711            block_lines.into_entries(),
2712        ),
2713    );
2714
2715    // Extract frontmatter and inject H1 title if no frontmatter title exists
2716    let mut frontmatter = yaml_frontmatter_simplified(&state.metadata_parsed);
2717    if !frontmatter.contains_key("title")
2718        && let Some(h1_text) = headings
2719            .first()
2720            .filter(|h| h.level == 1)
2721            .map(|h| h.text.clone())
2722    {
2723        frontmatter.insert("title".to_string(), serde_json::Value::String(h1_text));
2724    }
2725
2726    let contact = state
2727        .metadata_parsed
2728        .as_ref()
2729        .and_then(crate::contact::Contact::from_yaml)
2730        .map(|mut contact| {
2731            contact.image = contact
2732                .image
2733                .map(|src| transform_link(&src, &state.link_transform_config));
2734            contact
2735        });
2736
2737    Ok(MarkdownRenderResult {
2738        frontmatter,
2739        frontmatter_error: state.frontmatter_error,
2740        headings,
2741        html: html_output,
2742        outbound_links: deduplicated_links,
2743        has_h1,
2744        word_count: state.word_count,
2745        sentence_count: state.sentence_count,
2746        syllable_count: state.syllable_count,
2747        ambiguous_wikilinks: state.ambiguous_wikilinks,
2748        contact,
2749    })
2750}
2751
2752/// Synchronous counterpart of [`render_with_cache`] for use from rayon threads:
2753/// reads `file` and renders it with [`render_str`].
2754///
2755/// No-network embeds (Giphy, GitHub gist, and bare-URL media) ARE produced in the
2756/// sync path — they are pure CPU (regex/string) and require no I/O, so they work in
2757/// build mode regardless of `oembed_timeout_ms`. Only OpenGraph network fetches are
2758/// skipped here; when `oembed_timeout_ms > 0` and a cache is present, previously
2759/// cached network results are also merged in (but never fetched fresh).
2760#[allow(clippy::too_many_arguments)]
2761pub fn render_sync(
2762    file: PathBuf,
2763    root_path: &Path,
2764    oembed_timeout_ms: u64,
2765    link_transform_config: LinkTransformConfig,
2766    oembed_cache: Option<Arc<OembedCache>>,
2767    server_mode: bool,
2768    transcode_enabled: bool,
2769    valid_tag_sources: HashSet<String>,
2770    review: ReviewLines,
2771    mark_incomplete: bool,
2772    incomplete_markers: &[String],
2773    wikilink_index: Option<Arc<WikilinkIndex>>,
2774) -> Result<MarkdownRenderResult, MarkdownError> {
2775    let raw_markdown_input = fs::read_to_string(&file).map_err(|e| MarkdownError::ReadFailed {
2776        path: file.clone(),
2777        source: e,
2778    })?;
2779    render_str(
2780        &raw_markdown_input,
2781        &file,
2782        RenderOptions {
2783            root_path,
2784            oembed_timeout_ms,
2785            link_transform_config,
2786            oembed_cache,
2787            server_mode,
2788            transcode_enabled,
2789            valid_tag_sources,
2790            review,
2791            mark_incomplete,
2792            incomplete_markers,
2793            wikilink_index,
2794        },
2795    )
2796}
2797
2798/// [`render_sync`] for a file in a vault: `file` is its index key, read
2799/// through `vault` ([`read_source`]) and rendered identically.
2800#[allow(clippy::too_many_arguments)]
2801pub fn render_sync_from_vault(
2802    vault: &dyn crate::vault::Vault,
2803    file: PathBuf,
2804    root_path: &Path,
2805    oembed_timeout_ms: u64,
2806    link_transform_config: LinkTransformConfig,
2807    oembed_cache: Option<Arc<OembedCache>>,
2808    server_mode: bool,
2809    transcode_enabled: bool,
2810    valid_tag_sources: HashSet<String>,
2811    review: ReviewLines,
2812    mark_incomplete: bool,
2813    incomplete_markers: &[String],
2814    wikilink_index: Option<Arc<WikilinkIndex>>,
2815) -> Result<MarkdownRenderResult, MarkdownError> {
2816    let raw_markdown_input = read_source(vault, &file)?;
2817    render_str(
2818        &raw_markdown_input,
2819        &file,
2820        RenderOptions {
2821            root_path,
2822            oembed_timeout_ms,
2823            link_transform_config,
2824            oembed_cache,
2825            server_mode,
2826            transcode_enabled,
2827            valid_tag_sources,
2828            review,
2829            mark_incomplete,
2830            incomplete_markers,
2831            wikilink_index,
2832        },
2833    )
2834}
2835
2836/// Extract only the outbound links of a markdown file.
2837///
2838/// Runs the same sync pipeline as [`render_sync`] — BOM strip, tag-wikilink
2839/// substitution, event collection, `process_all_events` (which is where link
2840/// transformation and `[[wikilink]]` resolution happen) — and then stops,
2841/// skipping HTML generation and frontmatter extraction. Callers that need the
2842/// rendered page should use [`render_sync`]; this exists for the server's
2843/// repository-wide backlink index, which parses every markdown file once and
2844/// only ever looks at the collected links.
2845///
2846/// Links are deduplicated by target exactly as [`finalize_render`] does, so a
2847/// page's link list is identical whichever entry point produced it.
2848///
2849/// Network oembed is not consulted (the sync pipeline never fetches anyway).
2850/// That can change which *external* links are collected — a bare URL that
2851/// would have become an embed stays an autolink — but never the internal ones,
2852/// which are all the backlink index inverts.
2853pub fn extract_outbound_links_sync(
2854    file: PathBuf,
2855    root_path: &Path,
2856    link_transform_config: LinkTransformConfig,
2857    server_mode: bool,
2858    valid_tag_sources: HashSet<String>,
2859    wikilink_index: Option<Arc<WikilinkIndex>>,
2860) -> Result<Vec<OutboundLink>, MarkdownError> {
2861    let raw_markdown_input = fs::read_to_string(&file).map_err(|e| MarkdownError::ReadFailed {
2862        path: file.clone(),
2863        source: e,
2864    })?;
2865    Ok(extract_outbound_links_from_str(
2866        &raw_markdown_input,
2867        &file,
2868        root_path,
2869        link_transform_config,
2870        server_mode,
2871        valid_tag_sources,
2872        wikilink_index,
2873    ))
2874}
2875
2876/// [`extract_outbound_links_sync`] for a file in a vault: `file` is its index
2877/// key, read through `vault` ([`read_source`]).
2878pub fn extract_outbound_links_from_vault(
2879    vault: &dyn crate::vault::Vault,
2880    file: &Path,
2881    root_path: &Path,
2882    link_transform_config: LinkTransformConfig,
2883    server_mode: bool,
2884    valid_tag_sources: HashSet<String>,
2885    wikilink_index: Option<Arc<WikilinkIndex>>,
2886) -> Result<Vec<OutboundLink>, MarkdownError> {
2887    let raw_markdown_input = read_source(vault, file)?;
2888    Ok(extract_outbound_links_from_str(
2889        &raw_markdown_input,
2890        file,
2891        root_path,
2892        link_transform_config,
2893        server_mode,
2894        valid_tag_sources,
2895        wikilink_index,
2896    ))
2897}
2898
2899/// The outbound links of already-read markdown text; `file` only names it in
2900/// diagnostics. See [`extract_outbound_links_sync`].
2901pub fn extract_outbound_links_from_str(
2902    raw_markdown_input: &str,
2903    file: &Path,
2904    root_path: &Path,
2905    link_transform_config: LinkTransformConfig,
2906    server_mode: bool,
2907    valid_tag_sources: HashSet<String>,
2908    wikilink_index: Option<Arc<WikilinkIndex>>,
2909) -> Vec<OutboundLink> {
2910    let markdown_input = prepare_source(raw_markdown_input, &valid_tag_sources);
2911
2912    // Task markup is skipped: it rewrites text runs, never link destinations,
2913    // so it cannot change which links this function collects -- and this runs
2914    // over every markdown file in the repository. Text-line and block-line
2915    // recording are off for the same reason: there is no pass 3 and no HTML
2916    // here, and a `LineIndex` per file in the repository would be pure waste.
2917    let (events_with_ids, _headings, _section_attrs) = collect_events_and_headings(
2918        &markdown_input,
2919        TaskMarkup::Skip,
2920        &mut TextLines::disabled(),
2921        &mut BlockLines::disabled(),
2922    );
2923
2924    let prefetched_oembed = collect_local_embeds(&events_with_ids);
2925
2926    let (_processed_events, state) = process_all_events(
2927        events_with_ids,
2928        root_path,
2929        file,
2930        link_transform_config,
2931        prefetched_oembed,
2932        server_mode,
2933        false, // transcode_enabled: irrelevant to link collection
2934        valid_tag_sources,
2935        wikilink_index,
2936    );
2937
2938    let mut seen_targets: HashSet<String> = HashSet::new();
2939    state
2940        .collected_links
2941        .into_iter()
2942        .filter(|link| seen_targets.insert(link.to.clone()))
2943        .collect()
2944}
2945
2946/// Compute no-network oembed results (Giphy, gist, bare-URL media) for all
2947/// bare URLs in `events`. Pure/synchronous — safe for the build (rayon) path.
2948fn collect_local_embeds(events: &[Event<'_>]) -> HashMap<String, PageInfo> {
2949    collect_bare_urls(events)
2950        .into_iter()
2951        .filter_map(|url| PageInfo::local_embed(&url).map(|info| (url, info)))
2952        .collect()
2953}
2954
2955/// Collect oembed results from cache only (no network fetches).
2956///
2957/// Used by `render_sync` to leverage cached oembed data without blocking on I/O.
2958fn collect_cached_oembed(events: &[Event<'_>], cache: &OembedCache) -> HashMap<String, PageInfo> {
2959    let urls = collect_bare_urls(events);
2960    let mut results = HashMap::new();
2961    for url in urls {
2962        if let Some(info) = cache.get(&url) {
2963            results.insert(url, info);
2964        }
2965    }
2966    results
2967}
2968
2969/// Pre-pass to collect all bare URLs that need oembed fetching.
2970///
2971/// This identifies text events that look like bare URLs (start with "http", no spaces,
2972/// and not inside a link element). These URLs are then fetched in parallel for better
2973/// performance.
2974///
2975/// Code blocks are skipped: a URL that only appears in a code sample is content,
2976/// not a link, and fetching it would make mbr issue an outbound request for text
2977/// the author never intended to embed.
2978fn collect_bare_urls(events: &[Event<'_>]) -> HashSet<String> {
2979    let mut urls = HashSet::new();
2980    let mut in_link = false;
2981    let mut in_metadata = false;
2982    let mut in_code_block = false;
2983
2984    for event in events {
2985        match event {
2986            Event::Start(Tag::Link { .. }) => in_link = true,
2987            Event::End(TagEnd::Link) => in_link = false,
2988            Event::Start(Tag::MetadataBlock(_)) => in_metadata = true,
2989            Event::End(TagEnd::MetadataBlock(_)) => in_metadata = false,
2990            Event::Start(Tag::CodeBlock(_)) => in_code_block = true,
2991            Event::End(TagEnd::CodeBlock) => in_code_block = false,
2992            Event::Text(text)
2993                if !in_link
2994                    && !in_metadata
2995                    && !in_code_block
2996                    && text.starts_with("http")
2997                    && !text.contains(' ')
2998                    && !text.trim_start().starts_with("{{") =>
2999            {
3000                urls.insert(text.to_string());
3001            }
3002            _ => {}
3003        }
3004    }
3005
3006    urls
3007}
3008
3009/// Maximum number of oembed URLs fetched concurrently for a single document.
3010///
3011/// Small on purpose: unbounded fan-out (one in-flight request per distinct bare
3012/// URL) pressures the file-descriptor limit and turns a single markdown file
3013/// into a traffic amplifier against whatever host it names.
3014const OEMBED_FETCH_CONCURRENCY: usize = 8;
3015
3016/// Maximum number of distinct bare URLs a single document fetches oembed
3017/// metadata for. URLs beyond the cap render as plain links.
3018const MAX_OEMBED_FETCHES_PER_DOC: usize = 100;
3019
3020/// Applies [`MAX_OEMBED_FETCHES_PER_DOC`] to the list of URLs still needing a
3021/// network fetch.
3022///
3023/// Sorts before truncating so the surviving subset is deterministic:
3024/// `collect_bare_urls` returns a `HashSet`, whose iteration order varies from
3025/// process to process, which would otherwise make static builds irreproducible.
3026/// The sort is skipped entirely when the document is under the cap.
3027fn cap_fetch_list(mut urls: Vec<String>) -> Vec<String> {
3028    if urls.len() > MAX_OEMBED_FETCHES_PER_DOC {
3029        tracing::warn!(
3030            "oembed prefetch: {} bare URLs exceeds the per-document cap of {}; \
3031             the remainder will render as plain links",
3032            urls.len(),
3033            MAX_OEMBED_FETCHES_PER_DOC
3034        );
3035        urls.sort_unstable();
3036        urls.truncate(MAX_OEMBED_FETCHES_PER_DOC);
3037    }
3038    urls
3039}
3040
3041/// Fetches oembed data for a collection of URLs in parallel.
3042///
3043/// Uses the cache when available to avoid redundant network requests.
3044/// New results are stored in the cache for future use.
3045///
3046/// Concurrency is bounded by [`OEMBED_FETCH_CONCURRENCY`] and the number of
3047/// fetches per document by [`MAX_OEMBED_FETCHES_PER_DOC`].
3048async fn prefetch_oembed_urls(
3049    events: &[Event<'_>],
3050    oembed_timeout_ms: u64,
3051    oembed_cache: &Option<Arc<OembedCache>>,
3052) -> HashMap<String, PageInfo> {
3053    let urls = collect_bare_urls(events);
3054
3055    if urls.is_empty() {
3056        return HashMap::new();
3057    }
3058
3059    tracing::debug!("oembed prefetch: found {} bare URLs to fetch", urls.len());
3060
3061    // Partition URLs into cached and uncached
3062    let (cached, uncached): (Vec<_>, Vec<_>) = urls
3063        .into_iter()
3064        .partition(|url| oembed_cache.as_ref().and_then(|c| c.get(url)).is_some());
3065
3066    let mut results = HashMap::new();
3067
3068    // Add cached results
3069    if let Some(cache) = oembed_cache {
3070        for url in cached {
3071            if let Some(info) = cache.get(&url) {
3072                results.insert(url, info);
3073            }
3074        }
3075    }
3076
3077    // Fetch uncached URLs with bounded concurrency
3078    let to_fetch = cap_fetch_list(uncached);
3079    if !to_fetch.is_empty() {
3080        use futures::stream::StreamExt;
3081
3082        tracing::debug!(
3083            "oembed prefetch: {} cached, {} to fetch",
3084            results.len(),
3085            to_fetch.len()
3086        );
3087
3088        let fetched: Vec<_> = futures::stream::iter(to_fetch)
3089            .map(|url| async move {
3090                tracing::debug!("oembed fetch start: {}", url);
3091                let result = PageInfo::new_from_url(&url, oembed_timeout_ms)
3092                    .await
3093                    .unwrap_or_else(|_| PageInfo {
3094                        url: url.clone(),
3095                        ..Default::default()
3096                    });
3097                tracing::debug!("oembed fetch complete: {}", url);
3098                (url, result)
3099            })
3100            .buffer_unordered(OEMBED_FETCH_CONCURRENCY)
3101            .collect()
3102            .await;
3103
3104        // Store results and cache them
3105        for (url, info) in fetched {
3106            if let Some(cache) = oembed_cache {
3107                cache.insert(url.clone(), info.clone());
3108            }
3109            results.insert(url, info);
3110        }
3111    }
3112
3113    results
3114}
3115
3116/// The simplified frontmatter every page feature and `site.json` reads.
3117///
3118/// The one entry point for all three parse paths (page render, metadata scan,
3119/// [`parse`]), so the contact normalisation applied here — flat `aliases`,
3120/// `dates.<label>` strings — holds everywhere. See
3121/// [`crate::contact::normalize_simplified`].
3122fn yaml_frontmatter_simplified(y: &Option<Yaml>) -> SimpleMetadata {
3123    match y.as_ref().and_then(|yaml| yaml.as_hash()) {
3124        Some(hash) => {
3125            let mut hm = yaml_hash_to_metadata(hash);
3126            crate::contact::normalize_simplified(&mut hm, hash);
3127            hm
3128        }
3129        None => SimpleMetadata::new(),
3130    }
3131}
3132
3133/// Converts a YAML hash to simplified metadata, borrowing instead of cloning.
3134fn yaml_hash_to_metadata(hash: &yaml_rust2::yaml::Hash) -> SimpleMetadata {
3135    let mut hm = SimpleMetadata::new();
3136    for (k, v) in hash.iter() {
3137        match (k, v) {
3138            (Yaml::String(key), Yaml::String(value)) => {
3139                tracing::trace!("Frontmatter: {key} = {value}");
3140                hm.insert(key.clone(), serde_json::Value::String(value.clone()));
3141            }
3142            (Yaml::String(key), Yaml::Array(vals)) => {
3143                // Preserve arrays as JSON arrays instead of joining them
3144                let arr: Vec<serde_json::Value> = vals
3145                    .iter()
3146                    .filter_map(|val| val.as_str())
3147                    .map(|s| serde_json::Value::String(s.to_string()))
3148                    .collect();
3149                tracing::trace!("Frontmatter: {key} = {:?}", &arr);
3150                hm.insert(key.clone(), serde_json::Value::Array(arr));
3151            }
3152            (Yaml::String(key), Yaml::Hash(nested_hash)) => {
3153                tracing::trace!("Frontmatter: {key} = (nested hash)");
3154                // Recursively parse nested hashes and flatten with dot notation
3155                let nested = yaml_hash_to_metadata(nested_hash);
3156                for (k, v) in nested {
3157                    hm.insert(key.to_string() + "." + k.as_str(), v);
3158                }
3159            }
3160            (Yaml::String(key), Yaml::Integer(val)) => {
3161                tracing::trace!("Frontmatter: {key} = {val}");
3162                hm.insert(key.clone(), serde_json::json!(val));
3163            }
3164            (Yaml::String(key), Yaml::Real(val)) => {
3165                tracing::trace!("Frontmatter: {key} = {val}");
3166                hm.insert(key.clone(), serde_json::Value::String(val.clone()));
3167            }
3168            (Yaml::String(key), Yaml::Boolean(val)) => {
3169                tracing::trace!("Frontmatter: {key} = {val}");
3170                hm.insert(key.clone(), serde_json::json!(val));
3171            }
3172            (Yaml::String(key), other_val) => {
3173                tracing::trace!("Frontmatter: {key} = {:?}", &other_val);
3174                if let Some(str_val) = other_val.as_str() {
3175                    hm.insert(key.clone(), serde_json::Value::String(str_val.to_string()));
3176                }
3177            }
3178            (k, v) => {
3179                tracing::warn!("Unexpected frontmatter key-value: {:?} = {:?}", k, v);
3180            }
3181        }
3182    }
3183    hm
3184}
3185
3186/// Maximum bytes to read when extracting frontmatter metadata.
3187/// Frontmatter should always be at the top of the file, so 8KB is plenty.
3188const FRONTMATTER_MAX_BYTES: usize = 8 * 1024;
3189
3190/// Frontmatter extracted from a file: the simplified metadata map plus the
3191/// typed relationships parsed from the raw YAML.
3192///
3193/// Returning both from a single read avoids a second file read for the typed
3194/// relationship path (the simplified map is lossy for array-of-object fields).
3195#[derive(Debug, Clone, Default)]
3196pub struct FileMetadata {
3197    /// The simplified frontmatter metadata (string/array/scalar values).
3198    pub metadata: SimpleMetadata,
3199    /// Typed relationships declared in frontmatter (unresolved endpoints).
3200    pub relationships: Vec<crate::relationships::RawRelationship>,
3201}
3202
3203pub fn extract_metadata_from_file<P: AsRef<Path>>(path: P) -> Result<FileMetadata, MarkdownError> {
3204    let path = path.as_ref();
3205    // Only read the first 8KB - frontmatter is always at the top
3206    let buffer = crate::vault::read_prefix_native(path, FRONTMATTER_MAX_BYTES).map_err(|e| {
3207        MarkdownError::ReadFailed {
3208            path: path.to_path_buf(),
3209            source: e,
3210        }
3211    })?;
3212    Ok(extract_metadata_from_bytes(path, &buffer))
3213}
3214
3215/// [`extract_metadata_from_file`] for a file in a vault: the same 8 KB read,
3216/// through [`crate::vault::Vault::read_prefix`].
3217///
3218/// Errors (and the YAML warning) name the file by its [`Vault::key`], which for
3219/// a [`crate::vault::LocalVault`] is the path the old file-based call printed.
3220///
3221/// [`Vault::key`]: crate::vault::Vault::key
3222pub fn extract_metadata_from_vault(
3223    vault: &dyn crate::vault::Vault,
3224    path: &crate::vault::VaultPath,
3225) -> Result<FileMetadata, MarkdownError> {
3226    let key = vault.key(path);
3227    let buffer = vault
3228        .read_prefix(path, FRONTMATTER_MAX_BYTES)
3229        .map_err(|e| MarkdownError::ReadFailed {
3230            path: key.clone(),
3231            source: e.into(),
3232        })?;
3233    Ok(extract_metadata_from_bytes(&key, &buffer))
3234}
3235
3236/// Frontmatter (and the first-H1 title fallback) from the **first bytes** of a
3237/// markdown file — [`FRONTMATTER_MAX_BYTES`] of them is always enough, since
3238/// frontmatter is at the top. `path` is only used to name the file in the
3239/// warning a malformed YAML block logs.
3240///
3241/// Invalid UTF-8 is decoded lossily: a prefix can end mid-character, and a
3242/// stray byte in the body must not cost the note its metadata.
3243pub fn extract_metadata_from_bytes(path: &Path, buffer: &[u8]) -> FileMetadata {
3244    let decoded = String::from_utf8_lossy(buffer);
3245    let markdown_input = strip_bom(&decoded);
3246    let parser = MDParser::new_ext(markdown_input, Options::ENABLE_YAML_STYLE_METADATA_BLOCKS);
3247    let parser = TextMergeStream::new(parser);
3248    let mut in_metadata = false;
3249    let mut hm = SimpleMetadata::new();
3250    let mut relationships = Vec::new();
3251    for event in parser.take(4) {
3252        match &event {
3253            Event::Start(Tag::MetadataBlock(MetadataBlockKind::YamlStyle)) => {
3254                in_metadata = true;
3255            }
3256            Event::End(TagEnd::MetadataBlock(MetadataBlockKind::YamlStyle)) => {
3257                break;
3258            }
3259            Event::Text(text) if in_metadata => {
3260                // A parse failure discards the *entire* frontmatter: `type`,
3261                // `aliases`, `relationships` and all. This is the scan path that
3262                // feeds the relationship index, so a silent drop here shows up
3263                // as a flood of "unresolved relationship endpoint" warnings from
3264                // other notes that referenced this one by an alias that never
3265                // made it into the index. Log it, naming the file, then fall
3266                // back to empty metadata exactly as before so the scan continues.
3267                let metadata_parsed = match YamlLoader::load_from_str(text) {
3268                    Ok(docs) => docs.into_iter().next(),
3269                    Err(e) => {
3270                        tracing::warn!(
3271                            path = %path.display(),
3272                            "Failed to parse YAML frontmatter: {e}; the whole \
3273                             frontmatter block (including any `aliases` and \
3274                             `relationships`) is ignored for this note"
3275                        );
3276                        None
3277                    }
3278                };
3279
3280                if let Some(ref yaml) = metadata_parsed {
3281                    relationships = crate::relationships::parse_relationships(yaml);
3282                }
3283                hm = yaml_frontmatter_simplified(&metadata_parsed);
3284                break;
3285            }
3286            _ => {}
3287        }
3288    }
3289
3290    // If no frontmatter title, try to extract the first H1 from the content
3291    if !hm.contains_key("title")
3292        && let Some(h1_text) = extract_first_h1(markdown_input)
3293    {
3294        hm.insert("title".to_string(), serde_json::Value::String(h1_text));
3295    }
3296
3297    FileMetadata {
3298        metadata: hm,
3299        relationships,
3300    }
3301}
3302
3303/// Lowercases `text` and reduces it to alphanumerics and `-`.
3304///
3305/// Whitespace becomes `-`; every other non-alphanumeric character is dropped
3306/// *and* splits the text there, with the pieces rejoined by `-`. Punctuation
3307/// between two words therefore adds a separator rather than removing one:
3308/// `Hello, World!` slugifies to `hello--world`, the comma's rejoin dash landing
3309/// beside the one the space already produced. The doubled dash is load bearing
3310/// — these slugs are the `#anchor` targets of hand-written links in existing
3311/// repositories and cannot be "fixed" without breaking them.
3312///
3313/// Returns an empty string for input with nothing to keep; callers decide what
3314/// an empty slug means (`generate_anchor_id` substitutes `heading`, the body
3315/// class builder in `templates.rs` drops it).
3316pub(crate) fn slugify(text: &str) -> String {
3317    text.to_lowercase()
3318        .chars()
3319        .map(|c| {
3320            if c.is_alphanumeric() || c == '-' {
3321                c
3322            } else if c.is_whitespace() {
3323                '-'
3324            } else {
3325                // Remove special characters
3326                ' '
3327            }
3328        })
3329        .collect::<String>()
3330        .split_whitespace()
3331        .collect::<Vec<_>>()
3332        .join("-")
3333}
3334
3335/// Generates a URL-safe anchor ID from heading text.
3336///
3337/// Handles duplicates by appending `-2`, `-3`, … and guarantees the emitted id
3338/// is unique across the whole document. `anchor_ids` therefore doubles as the
3339/// set of already-issued ids: a bare per-base counter is not sufficient, because
3340/// the composed suffix can collide with a *different* heading whose own slug
3341/// happens to match it (`["Step 1", "Step 1", "Step 1-2"]` would otherwise hand
3342/// out `step-1-2` twice).
3343fn generate_anchor_id(text: &str, anchor_ids: &mut HashMap<String, usize>) -> String {
3344    let base_id = slugify(text);
3345
3346    // Handle empty IDs
3347    let base_id = if base_id.is_empty() {
3348        "heading".to_string()
3349    } else {
3350        base_id
3351    };
3352
3353    // Walk the suffix upward until the composed candidate is genuinely unused.
3354    let mut count = anchor_ids.get(&base_id).copied().unwrap_or(0);
3355    let candidate = loop {
3356        count += 1;
3357        let candidate = if count == 1 {
3358            base_id.clone()
3359        } else {
3360            format!("{}-{}", base_id, count)
3361        };
3362        if !anchor_ids.contains_key(&candidate) {
3363            break candidate;
3364        }
3365    };
3366
3367    anchor_ids.insert(base_id, count);
3368    // Reserve the emitted id itself so a later heading that slugifies straight
3369    // to it is pushed onto a different suffix instead of colliding.
3370    anchor_ids.entry(candidate.clone()).or_insert(0);
3371    candidate
3372}
3373
3374/// Processes a single markdown event, transforming it as needed.
3375///
3376/// This function is now synchronous because all async work (oembed fetching)
3377/// is done in the prefetch phase. Bare URLs are looked up in the prefetched
3378/// results instead of being fetched inline.
3379///
3380/// # Invariant
3381///
3382/// Exactly one event out for one event in. Arms may *rewrite* an event — a bare
3383/// URL and a `{{ vid(…) }}` shortcode both turn a `Text` into an `Html`, and a
3384/// media `Start(Image)` / `End(Image)` pair becomes two `Html`s — but nothing
3385/// here may insert, drop or reorder. [`TextLines`] records event indices before
3386/// this pass and reads them back after it, so a single inserted event would
3387/// silently misattribute every marker anchor after it. A rewritten `Text` is
3388/// harmless: its record simply goes unread.
3389fn process_event(
3390    event: pulldown_cmark::Event<'_>,
3391    mut state: EventState,
3392) -> (pulldown_cmark::Event<'_>, EventState) {
3393    match &event {
3394        Event::Start(Tag::Image {
3395            link_type,
3396            dest_url,
3397            title,
3398            id,
3399        }) => {
3400            // Transform the URL first for trailing-slash URL convention
3401            // This applies to all images/media, not just regular images
3402            let transformed_url = transform_link(dest_url, &state.link_transform_config);
3403
3404            match MediaEmbed::from_url_and_title(&transformed_url, title) {
3405                Some(media) => {
3406                    // the link title is actually the next Text event so need to split this to only produce the open tags
3407                    let html = media.to_html(true, state.server_mode, state.transcode_enabled);
3408                    state.current_media = Some(media);
3409                    (Event::Html(html.into()), state)
3410                }
3411                _ => {
3412                    let new_event = Event::Start(Tag::Image {
3413                        link_type: *link_type,
3414                        dest_url: CowStr::from(transformed_url),
3415                        title: title.clone(),
3416                        id: id.clone(),
3417                    });
3418                    (new_event, state)
3419                }
3420            }
3421        }
3422        Event::End(TagEnd::Image) => {
3423            if let Some(media) = state.current_media.take() {
3424                (Event::Html(media.html_close().into()), state)
3425            } else {
3426                (event, state)
3427            }
3428        }
3429        Event::Start(Tag::MetadataBlock(v)) => {
3430            state.metadata_source = Some(*v);
3431            state.in_metadata = true;
3432            (event.clone(), state)
3433        }
3434        Event::End(TagEnd::MetadataBlock(_)) => {
3435            state.in_metadata = false;
3436            (event.clone(), state)
3437        }
3438        // Track when we're inside a link (including autolinks like <http://...>)
3439        // and transform the link URL for trailing-slash URL convention
3440        // Also detect and transform tag links like [text](Tags:rust) -> [text](/tags/rust/)
3441        Event::Start(Tag::Link {
3442            link_type,
3443            dest_url,
3444            title,
3445            id,
3446        }) => {
3447            state.in_link = true;
3448            // Store the original destination URL for link tracking
3449            state.current_link_dest = Some(dest_url.to_string());
3450            state.current_link_text.clear();
3451
3452            // First check if this is a tag link (e.g., Tags:rust, performers:Joshua Jay)
3453            // If so, transform to the tag URL path (/tags/rust/, /performers/joshua_jay/)
3454            let transformed_url =
3455                if let Some(wikilink) = parse_tag_link(dest_url, &state.valid_tag_sources) {
3456                    transform_link(&wikilink.url_path(), &state.link_transform_config)
3457                } else {
3458                    // Not a tag link. For bare-name body wikilinks (`[[Name]]`),
3459                    // apply Obsidian-style global resolution: current folder first,
3460                    // else the first matching file anywhere. `resolve_wikilink`
3461                    // returns Some only for the global-fallback case, so same-folder
3462                    // links keep the default relative transform byte-for-byte.
3463                    let is_bare_wikilink =
3464                        matches!(link_type, LinkType::WikiLink { .. }) && !dest_url.contains('/');
3465                    let global = if is_bare_wikilink {
3466                        state.wikilink_index.as_ref().and_then(|idx| {
3467                            idx.resolve_wikilink(
3468                                dest_url,
3469                                &state.link_transform_config.current_page_url,
3470                                state.link_transform_config.is_index_file,
3471                            )
3472                        })
3473                    } else {
3474                        None
3475                    };
3476                    // Record namesake ambiguity separately from `global`: a
3477                    // same-folder link needs no rewrite (`global` is None) yet can
3478                    // still have resolved arbitrarily between two case-variant
3479                    // files, and a global-fallback link that *did* rewrite may have
3480                    // had several candidates to choose from.
3481                    let ambiguous = if is_bare_wikilink {
3482                        state.wikilink_index.as_ref().and_then(|idx| {
3483                            idx.ambiguity_for(
3484                                dest_url,
3485                                &state.link_transform_config.current_page_url,
3486                                state.link_transform_config.is_index_file,
3487                            )
3488                        })
3489                    } else {
3490                        None
3491                    };
3492                    if let Some(found) = ambiguous
3493                        && !state.ambiguous_wikilinks.contains(&found)
3494                    {
3495                        state.ambiguous_wikilinks.push(found);
3496                    }
3497                    match global {
3498                        Some(abs) => {
3499                            // Override the recorded outbound target with the absolute
3500                            // URL so link validation and backlinks resolve correctly
3501                            // (its leading `/` makes `resolve_outbound_links` leave it
3502                            // untouched, and the path resolver then finds it).
3503                            state.current_link_dest = Some(abs.clone());
3504                            transform_link(&abs, &state.link_transform_config)
3505                        }
3506                        None => transform_link(dest_url, &state.link_transform_config),
3507                    }
3508                };
3509
3510            let new_event = Event::Start(Tag::Link {
3511                link_type: *link_type,
3512                dest_url: CowStr::from(transformed_url),
3513                title: title.clone(),
3514                id: id.clone(),
3515            });
3516            (new_event, state)
3517        }
3518        Event::End(TagEnd::Link) => {
3519            state.in_link = false;
3520            // Collect the outbound link
3521            if let Some(dest_url) = state.current_link_dest.take() {
3522                let (path, anchor) = split_url_anchor(&dest_url);
3523                let internal = is_internal_link(&dest_url);
3524                let link = OutboundLink {
3525                    to: path,
3526                    text: std::mem::take(&mut state.current_link_text),
3527                    anchor,
3528                    internal,
3529                };
3530                state.collected_links.push(link);
3531            }
3532            (event, state)
3533        }
3534        // Track code blocks to exclude from word count
3535        Event::Start(Tag::CodeBlock(_)) => {
3536            state.in_code_block = true;
3537            (event, state)
3538        }
3539        Event::End(TagEnd::CodeBlock) => {
3540            state.in_code_block = false;
3541            (event, state)
3542        }
3543        // Block boundaries for readability's sentence count: paragraphs,
3544        // headings, and list items whose last text did not end in `.!?` get
3545        // one implicit sentence credit. This avoids undercounting headings
3546        // ("Introduction") and terse bullet items ("Install Rust").
3547        Event::End(TagEnd::Paragraph | TagEnd::Heading(_) | TagEnd::Item) => {
3548            if state.block_needs_sentence_bump {
3549                state.sentence_count += 1;
3550                state.block_needs_sentence_bump = false;
3551            }
3552            (event, state)
3553        }
3554        Event::Text(text) => {
3555            // Accumulate link text when inside a link
3556            if state.in_link {
3557                state.current_link_text.push_str(text);
3558            }
3559            // Count words, sentences, and syllables in text content
3560            // (excluding metadata and code blocks).
3561            if !state.in_metadata && !state.in_code_block {
3562                for word in text.split_whitespace() {
3563                    state.word_count += 1;
3564                    state.syllable_count += crate::readability::count_syllables(word);
3565                }
3566                let (sentences_in_text, ends_with_terminator) = count_sentence_terminators(text);
3567                state.sentence_count += sentences_in_text;
3568                // Track whether the enclosing block still needs a sentence
3569                // bump at its End tag. Trailing whitespace is ignored: we
3570                // care whether the last non-space character is `.!?`.
3571                let trimmed = text.trim_end();
3572                if !trimmed.is_empty() {
3573                    state.block_needs_sentence_bump = !ends_with_terminator;
3574                }
3575            }
3576            if state.in_metadata {
3577                match YamlLoader::load_from_str(text) {
3578                    Ok(docs) => state.metadata_parsed = docs.into_iter().next(),
3579                    Err(e) => {
3580                        // Invalid YAML aborts the whole frontmatter block, so
3581                        // otherwise-valid fields (e.g. `style: slides`) are
3582                        // silently lost. Capture the error so it can be
3583                        // surfaced to the user instead of disappearing, and name
3584                        // the file — the error alone does not identify which of
3585                        // a repository's notes to go and fix.
3586                        tracing::warn!(
3587                            path = %state.file_path.display(),
3588                            "Failed to parse YAML frontmatter: {e}"
3589                        );
3590                        state.frontmatter_error = Some(e.to_string());
3591                    }
3592                }
3593                (event, state)
3594            } else if state.in_code_block {
3595                // Code blocks are verbatim: the vid shortcode and bare-URL
3596                // oembed rewrites below must never fire on sample code. Checked
3597                // after `in_metadata` (not folded into it) so we never run the
3598                // YAML loader over code text.
3599                (event, state)
3600            } else if !state.in_link && text.starts_with("http") && !text.contains(' ') {
3601                // Only process bare URLs that are NOT inside a link element.
3602                // URLs in <http://...> autolinks or [text](url) links are already
3603                // handled by markdown and shouldn't trigger oembed fetching.
3604                //
3605                // Look up the prefetched result instead of fetching inline.
3606                let url_str = text.to_string();
3607                let info = state
3608                    .prefetched_oembed
3609                    .get(&url_str)
3610                    .cloned()
3611                    .unwrap_or_else(|| PageInfo {
3612                        url: url_str,
3613                        ..Default::default()
3614                    });
3615                (Event::Html(info.html().into()), state)
3616            } else if text.trim_start().starts_with("{{") {
3617                if let Some(mut vid) = Vid::from_vid(text) {
3618                    vid.url = transform_link(&vid.url, &state.link_transform_config);
3619                    (
3620                        Event::Html(
3621                            vid.to_html(false, state.server_mode, state.transcode_enabled)
3622                                .into(),
3623                        ),
3624                        state,
3625                    )
3626                } else {
3627                    (event, state)
3628                }
3629            } else {
3630                (event, state)
3631            }
3632        }
3633        // Raw HTML: relative `src`/`href`/`poster` values resolve like the
3634        // markdown image and link arms above. A rewrite, never an insert.
3635        Event::Html(html) | Event::InlineHtml(html) => {
3636            match transform_raw_html_urls(html, &state.link_transform_config) {
3637                Cow::Borrowed(_) => (event, state),
3638                Cow::Owned(rewritten) => {
3639                    let rewritten = CowStr::from(rewritten);
3640                    let new_event = if matches!(event, Event::Html(_)) {
3641                        Event::Html(rewritten)
3642                    } else {
3643                        Event::InlineHtml(rewritten)
3644                    };
3645                    (new_event, state)
3646                }
3647            }
3648        }
3649        _ => (event, state),
3650    }
3651}
3652
3653#[cfg(test)]
3654mod tests {
3655    use super::*;
3656    use std::io::Write;
3657    use tempfile::NamedTempFile;
3658
3659    async fn render_markdown(content: &str) -> String {
3660        render_markdown_with_config(content, false, HashSet::new()).await
3661    }
3662
3663    async fn render_markdown_with_tags(content: &str, tag_sources: HashSet<String>) -> String {
3664        render_markdown_with_config(content, false, tag_sources).await
3665    }
3666
3667    /// Renders with an explicit `server_mode`, for asserting that output does
3668    /// *not* vary between a served page and a static build.
3669    async fn render_markdown_with_mode(content: &str, server_mode: bool) -> String {
3670        render_str_with_mode(content, server_mode).html
3671    }
3672
3673    /// Options for an in-memory render rooted at `root`: no oembed, no review
3674    /// lines, no incomplete markers, no tag sources.
3675    fn plain_options(root: &Path, server_mode: bool) -> RenderOptions<'_> {
3676        RenderOptions {
3677            root_path: root,
3678            oembed_timeout_ms: 0,
3679            link_transform_config: LinkTransformConfig {
3680                markdown_extensions: vec!["md".to_string()],
3681                index_file: "index.md".to_string(),
3682                is_index_file: false,
3683                url_depth: None,
3684                current_page_url: String::new(),
3685                markdown_page_probe: None,
3686            },
3687            oembed_cache: None,
3688            server_mode,
3689            transcode_enabled: false,
3690            valid_tag_sources: HashSet::new(),
3691            review: ReviewLines::Omit,
3692            mark_incomplete: false,
3693            incomplete_markers: &[],
3694            wikilink_index: None,
3695        }
3696    }
3697
3698    /// Renders `content` without touching the filesystem.
3699    fn render_str_with_mode(content: &str, server_mode: bool) -> MarkdownRenderResult {
3700        let root = Path::new("/nonexistent-mbr-test-root");
3701        render_str(
3702            content,
3703            &root.join("note.md"),
3704            plain_options(root, server_mode),
3705        )
3706        .unwrap()
3707    }
3708
3709    #[test]
3710    fn render_str_strips_bom_before_frontmatter() {
3711        let result = render_str_with_mode("\u{feff}---\ntitle: Hi\n---\n\n# Body\n", false);
3712        assert_eq!(
3713            result.frontmatter.get("title").and_then(|v| v.as_str()),
3714            Some("Hi")
3715        );
3716        assert!(result.html.contains("Body"));
3717        assert!(!result.html.contains("title: Hi"));
3718    }
3719
3720    /// `render_sync` is `render_str` plus a file read, and the async path
3721    /// shares every pass with it: all three must produce identical pages, with
3722    /// every optional table (review lines, incomplete markers, tag wikilinks)
3723    /// switched on.
3724    #[tokio::test]
3725    async fn render_str_matches_file_entry_points() {
3726        let content = "---\ntitle: T\ntags: [a]\n---\n\n# Head TODO: one\n\n\
3727            - [ ] task @due(2026-01-02)\n- item with [[Tags:rust]] and https://example.com/x.mp4\n\n\
3728            ```rust\nfn x() {}\n```\n\n--- {#s .c}\n\nTerm\n: def\n";
3729        let mut file = NamedTempFile::new().unwrap();
3730        file.write_all(content.as_bytes()).unwrap();
3731        let path = file.path().to_path_buf();
3732        let root = path.parent().unwrap().to_path_buf();
3733        let markers: Vec<String> = DEFAULT_MARKERS.iter().map(|m| m.to_string()).collect();
3734        let tags: HashSet<String> = ["tags".to_string()].into_iter().collect();
3735        let opts = || RenderOptions {
3736            review: ReviewLines::Emit,
3737            mark_incomplete: true,
3738            incomplete_markers: &markers,
3739            valid_tag_sources: tags.clone(),
3740            ..plain_options(&root, true)
3741        };
3742
3743        let from_str = render_str(content, &path, opts()).unwrap();
3744        let o = opts();
3745        let from_sync = render_sync(
3746            path.clone(),
3747            o.root_path,
3748            o.oembed_timeout_ms,
3749            o.link_transform_config,
3750            o.oembed_cache,
3751            o.server_mode,
3752            o.transcode_enabled,
3753            o.valid_tag_sources,
3754            o.review,
3755            o.mark_incomplete,
3756            o.incomplete_markers,
3757            o.wikilink_index,
3758        )
3759        .unwrap();
3760        let o = opts();
3761        let from_async = render_with_cache(
3762            path.clone(),
3763            o.root_path,
3764            o.oembed_timeout_ms,
3765            o.link_transform_config,
3766            o.oembed_cache,
3767            o.server_mode,
3768            o.transcode_enabled,
3769            o.valid_tag_sources,
3770            o.review,
3771            o.mark_incomplete,
3772            o.incomplete_markers,
3773            o.wikilink_index,
3774        )
3775        .await
3776        .unwrap();
3777
3778        assert!(from_str.html.contains("data-mbr-line"));
3779        assert!(from_str.html.contains("mbr-marker-"));
3780        assert_eq!(from_str.html, from_sync.html);
3781        assert_eq!(from_str.html, from_async.html);
3782        assert_eq!(from_str.frontmatter, from_sync.frontmatter);
3783        assert_eq!(from_str.outbound_links, from_async.outbound_links);
3784    }
3785
3786    async fn render_markdown_with_config(
3787        content: &str,
3788        is_index_file: bool,
3789        tag_sources: HashSet<String>,
3790    ) -> String {
3791        let mut file = NamedTempFile::new().unwrap();
3792        file.write_all(content.as_bytes()).unwrap();
3793        let path = file.path().to_path_buf();
3794        let root = path.parent().unwrap().to_path_buf();
3795        let config = LinkTransformConfig {
3796            markdown_extensions: vec!["md".to_string()],
3797            index_file: "index.md".to_string(),
3798            is_index_file,
3799            url_depth: None,
3800            current_page_url: String::new(),
3801            markdown_page_probe: None,
3802        };
3803        // Tests run with server_mode=false, transcode_enabled=false, mark_incomplete=false
3804        let result = render(
3805            path,
3806            &root,
3807            100,
3808            config,
3809            false,
3810            false,
3811            tag_sources,
3812            ReviewLines::Omit,
3813            false,
3814            &[],
3815            None,
3816        )
3817        .await
3818        .unwrap();
3819        result.html
3820    }
3821
3822    /// Render with `mark_incomplete = true` and the given marker list.
3823    async fn render_markdown_marked(content: &str, markers: &[&str]) -> String {
3824        render_result_marked(content, markers).await.html
3825    }
3826
3827    /// As [`render_markdown_marked`], but keeps the headings and frontmatter
3828    /// alongside the HTML.
3829    async fn render_result_marked(content: &str, markers: &[&str]) -> MarkdownRenderResult {
3830        let mut file = NamedTempFile::new().unwrap();
3831        file.write_all(content.as_bytes()).unwrap();
3832        let path = file.path().to_path_buf();
3833        let root = path.parent().unwrap().to_path_buf();
3834        let config = LinkTransformConfig {
3835            markdown_extensions: vec!["md".to_string()],
3836            index_file: "index.md".to_string(),
3837            is_index_file: false,
3838            url_depth: None,
3839            current_page_url: String::new(),
3840            markdown_page_probe: None,
3841        };
3842        let owned: Vec<String> = markers.iter().map(|s| s.to_string()).collect();
3843        render(
3844            path,
3845            &root,
3846            0,
3847            config,
3848            false,
3849            false,
3850            HashSet::new(),
3851            ReviewLines::Omit,
3852            true,
3853            &owned,
3854            None,
3855        )
3856        .await
3857        .unwrap()
3858    }
3859
3860    async fn render_result(content: &str) -> MarkdownRenderResult {
3861        let mut file = NamedTempFile::new().unwrap();
3862        file.write_all(content.as_bytes()).unwrap();
3863        let path = file.path().to_path_buf();
3864        let root = path.parent().unwrap().to_path_buf();
3865        let config = LinkTransformConfig {
3866            markdown_extensions: vec!["md".to_string()],
3867            index_file: "index.md".to_string(),
3868            is_index_file: false,
3869            url_depth: None,
3870            current_page_url: String::new(),
3871            markdown_page_probe: None,
3872        };
3873        render(
3874            path,
3875            &root,
3876            0,
3877            config,
3878            false,
3879            false,
3880            HashSet::new(),
3881            ReviewLines::Omit,
3882            false,
3883            &[],
3884            None,
3885        )
3886        .await
3887        .unwrap()
3888    }
3889
3890    /// Renders `content` with an explicit current-page URL and wikilink index,
3891    /// for exercising Obsidian-style body-wikilink resolution.
3892    async fn render_with_wikilinks(
3893        content: &str,
3894        current_page_url: &str,
3895        url_depth: Option<usize>,
3896        wikilink_index: Option<Arc<WikilinkIndex>>,
3897    ) -> MarkdownRenderResult {
3898        let mut file = NamedTempFile::new().unwrap();
3899        file.write_all(content.as_bytes()).unwrap();
3900        let path = file.path().to_path_buf();
3901        let root = path.parent().unwrap().to_path_buf();
3902        let config = LinkTransformConfig {
3903            markdown_extensions: vec!["md".to_string()],
3904            index_file: "index.md".to_string(),
3905            is_index_file: false,
3906            url_depth,
3907            current_page_url: current_page_url.to_string(),
3908            markdown_page_probe: None,
3909        };
3910        render(
3911            path,
3912            &root,
3913            0,
3914            config,
3915            true, // server_mode
3916            false,
3917            HashSet::new(),
3918            ReviewLines::Omit,
3919            false,
3920            &[],
3921            wikilink_index,
3922        )
3923        .await
3924        .unwrap()
3925    }
3926
3927    fn wikilink_note(url: &str, title: &str, stem: &str) -> crate::relationships::NoteRelInput {
3928        crate::relationships::NoteRelInput {
3929            url: url.to_string(),
3930            title: title.to_string(),
3931            stem: stem.to_string(),
3932            aliases: Vec::new(),
3933            is_index: false,
3934            relationships: Vec::new(),
3935        }
3936    }
3937
3938    #[tokio::test]
3939    async fn wikilink_global_fallback_rewrites_href_and_records_absolute_target() {
3940        // `Patrick Walsh.md` lives in a *different* folder than the page that
3941        // references `[[Patrick Walsh]]`, so the global fallback rewrites the
3942        // href to the file's absolute URL and records that absolute target.
3943        let index = Arc::new(WikilinkIndex::new());
3944        index.rebuild(&[
3945            wikilink_note("/walsh/patrick-walsh/", "Patrick Walsh", "patrick-walsh"),
3946            wikilink_note("/notes/family/", "Family", "family"),
3947        ]);
3948
3949        let result = render_with_wikilinks(
3950            "See [[Patrick Walsh]] here.",
3951            "/notes/family/",
3952            None, // server mode: absolute URL left as-is
3953            Some(index),
3954        )
3955        .await;
3956
3957        assert!(
3958            result.html.contains(r#"href="/walsh/patrick-walsh/""#),
3959            "expected absolute href, got: {}",
3960            result.html
3961        );
3962        assert_eq!(
3963            result.outbound_links[0].to, "/walsh/patrick-walsh/",
3964            "outbound target should be the absolute URL for validation/backlinks"
3965        );
3966    }
3967
3968    #[tokio::test]
3969    async fn wikilink_same_folder_keeps_default_transform() {
3970        // `patrick-walsh.md` is a same-folder sibling of the referencing page,
3971        // so the default relative transform is kept byte-for-byte (no absolute
3972        // rewrite, and the recorded target stays the raw relative name).
3973        let index = Arc::new(WikilinkIndex::new());
3974        index.rebuild(&[
3975            wikilink_note("/notes/patrick-walsh/", "Patrick Walsh", "patrick-walsh"),
3976            wikilink_note("/notes/family/", "Family", "family"),
3977        ]);
3978
3979        let result = render_with_wikilinks(
3980            "See [[patrick-walsh]] here.",
3981            "/notes/family/",
3982            None,
3983            Some(index),
3984        )
3985        .await;
3986
3987        assert!(
3988            !result.html.contains(r#"href="/notes/patrick-walsh/""#),
3989            "same-folder wikilink must not be rewritten to absolute: {}",
3990            result.html
3991        );
3992        assert!(
3993            result.html.contains(r#"href="../patrick-walsh""#),
3994            "expected default relative href, got: {}",
3995            result.html
3996        );
3997        assert_eq!(result.outbound_links[0].to, "patrick-walsh");
3998    }
3999
4000    #[tokio::test]
4001    async fn invalid_yaml_frontmatter_is_captured_not_swallowed() {
4002        // Regression: this frontmatter uses `*` list markers with TAB
4003        // indentation (invalid YAML). yaml-rust2 aborts the whole document, so
4004        // the otherwise-valid `style: slides` field is silently discarded.
4005        // We must capture the parse error rather than swallow it.
4006        let content =
4007            "---\ntitle: \"Hi\"\nstyle: slides\ntags:\n\t* presentation\n\t* ai\n---\n# Heading\n";
4008        let result = render_result(content).await;
4009
4010        assert!(
4011            result.frontmatter_error.is_some(),
4012            "expected a captured frontmatter parse error, got None"
4013        );
4014        // The valid `style` field is lost because the whole block failed to
4015        // parse — documents the failure mode the error report explains.
4016        assert!(
4017            !result.frontmatter.contains_key("style"),
4018            "expected style to be discarded when frontmatter fails to parse"
4019        );
4020    }
4021
4022    #[tokio::test]
4023    async fn valid_yaml_frontmatter_has_no_error() {
4024        let content = "---\ntitle: \"Hi\"\nstyle: slides\n---\n# Heading\n";
4025        let result = render_result(content).await;
4026        assert!(result.frontmatter_error.is_none());
4027        assert!(result.frontmatter.contains_key("style"));
4028    }
4029
4030    /// Every parse path goes through the contact hook: mixed `aliases` come out
4031    /// as a flat list of names, and a person's dates — legacy `born` included —
4032    /// as `dates.<label>` strings (the `site.json` contract).
4033    #[tokio::test]
4034    async fn simplified_frontmatter_normalizes_aliases_and_contact_dates() {
4035        let content = concat!(
4036            "---\n",
4037            "type: person\n",
4038            "title: Mary Doe\n",
4039            "born: 1898\n",
4040            "aliases:\n",
4041            "  - Mare\n",
4042            "  - maiden_name: Mary Smith\n",
4043            "dates:\n",
4044            "  anniversary: 06-10\n",
4045            "phones:\n",
4046            "  - mobile: \"555 0100\"\n",
4047            "---\n",
4048            "Body\n",
4049        );
4050        let dir = tempfile::tempdir().unwrap();
4051        let path = dir.path().join("mary.md");
4052        std::fs::write(&path, content).unwrap();
4053
4054        let scanned = extract_metadata_from_file(&path).unwrap().metadata;
4055        let rendered = render_result(content).await;
4056        let parsed = parse(&path).unwrap().frontmatter;
4057        for fm in [&scanned, &rendered.frontmatter, &parsed] {
4058            assert_eq!(fm["aliases"], serde_json::json!(["Mare", "Mary Smith"]));
4059            assert_eq!(fm["dates.birthday"], serde_json::json!("1898"));
4060            assert_eq!(fm["dates.anniversary"], serde_json::json!("--06-10"));
4061            assert_eq!(fm["born"], serde_json::json!(1898), "legacy key kept");
4062        }
4063
4064        let contact = rendered.contact.expect("person → contact");
4065        assert_eq!(contact.phones[0].href.as_deref(), Some("tel:5550100"));
4066        assert_eq!(contact.alias_phrases, ["aka Mare", "née Mary Smith"]);
4067    }
4068
4069    #[tokio::test]
4070    async fn contact_is_none_for_other_notes() {
4071        let result = render_result("---\ntype: event\n---\nBody\n").await;
4072        assert!(result.contact.is_none());
4073    }
4074
4075    /// Frontmatter with two `to:` keys in one `relationships:` entry — the exact
4076    /// shape that cost a user their whole `person` frontmatter.
4077    const DUPLICATE_KEY_FRONTMATTER: &str = concat!(
4078        "---\n",
4079        "type: person\n",
4080        "aliases:\n",
4081        "  - Johnny Doe\n",
4082        "relationships:\n",
4083        "  - type: parent\n",
4084        "    to: \"[[Mary Doe]]\"\n",
4085        "    to: \"[[Sam Doe]]\"\n",
4086        "---\n",
4087        "# John Doe\n",
4088    );
4089
4090    #[test]
4091    fn duplicate_frontmatter_key_warning_names_the_file() {
4092        // Regression: the repo-scan path discarded the parse error with `.ok()`
4093        // and logged nothing at all, so the note lost `type`, `aliases` and every
4094        // relationship with no way to tell which of hundreds of files was at
4095        // fault. The dropped `aliases` then produced a flood of "unresolved
4096        // relationship endpoint" warnings from *other* notes.
4097        let dir = tempfile::tempdir().unwrap();
4098        let path = dir.path().join("john-doe.md");
4099        std::fs::write(&path, DUPLICATE_KEY_FRONTMATTER).unwrap();
4100
4101        let (result, logs) =
4102            crate::test_support::capture_tracing(|| extract_metadata_from_file(&path));
4103
4104        // Fallback behaviour is unchanged: still `Ok`, with empty metadata, so
4105        // the repo scan carries on.
4106        let meta = result.expect("extraction must still succeed");
4107        assert!(meta.relationships.is_empty());
4108        assert!(!meta.metadata.contains_key("type"));
4109        assert!(!meta.metadata.contains_key("aliases"));
4110
4111        assert!(
4112            logs.contains("Failed to parse YAML frontmatter"),
4113            "expected a frontmatter warning, got: {logs}"
4114        );
4115        assert!(
4116            logs.contains("john-doe.md"),
4117            "the warning must name the file, got: {logs}"
4118        );
4119    }
4120
4121    #[test]
4122    fn render_frontmatter_warning_names_the_file() {
4123        // The render path captured the error but logged it without the path, so
4124        // a page-load warning still could not be traced to a file. Driven on this
4125        // thread so `capture_tracing`'s thread-local subscriber sees it.
4126        let dir = tempfile::tempdir().unwrap();
4127        let path = dir.path().join("broken-note.md");
4128        std::fs::write(&path, DUPLICATE_KEY_FRONTMATTER).unwrap();
4129
4130        let runtime = tokio::runtime::Builder::new_current_thread()
4131            .enable_all()
4132            .build()
4133            .unwrap();
4134        let (result, logs) = crate::test_support::capture_tracing(|| {
4135            runtime.block_on(render(
4136                path.clone(),
4137                dir.path(),
4138                0,
4139                LinkTransformConfig {
4140                    markdown_extensions: vec!["md".to_string()],
4141                    index_file: "index.md".to_string(),
4142                    is_index_file: false,
4143                    url_depth: None,
4144                    current_page_url: "/broken-note/".to_string(),
4145                    markdown_page_probe: None,
4146                },
4147                true,
4148                false,
4149                HashSet::new(),
4150                ReviewLines::Omit,
4151                false,
4152                &[],
4153                None,
4154            ))
4155        });
4156
4157        let result = result.expect("render must still succeed");
4158        assert!(result.frontmatter_error.is_some());
4159        assert!(
4160            logs.contains("Failed to parse YAML frontmatter"),
4161            "expected a frontmatter warning, got: {logs}"
4162        );
4163        assert!(
4164            logs.contains("broken-note.md"),
4165            "the warning must name the file, got: {logs}"
4166        );
4167    }
4168
4169    #[tokio::test]
4170    async fn ambiguous_body_wikilink_is_reported_without_changing_resolution() {
4171        // Two notes titled "John Doe": the `[[John Doe]]` in the body resolves to
4172        // the smaller URL as always, and the arbitrary choice is now reported.
4173        let index = Arc::new(WikilinkIndex::new());
4174        index.rebuild(&[
4175            wikilink_note("/people/john-jr/", "John Doe", "john-jr"),
4176            wikilink_note("/people/john-sr/", "John Doe", "john-sr"),
4177        ]);
4178
4179        let result = render_with_wikilinks(
4180            "His father was [[John Doe]], and also [[John Doe]] again.",
4181            "/notes/family/",
4182            None,
4183            Some(index),
4184        )
4185        .await;
4186
4187        // Resolution unchanged.
4188        assert!(
4189            result.html.contains(r#"href="/people/john-jr/""#),
4190            "expected the first-wins target, got: {}",
4191            result.html
4192        );
4193        // Reported once, deduped across the two occurrences.
4194        assert_eq!(result.ambiguous_wikilinks.len(), 1);
4195        let found = &result.ambiguous_wikilinks[0];
4196        assert_eq!(found.raw, "[[John Doe]]");
4197        assert_eq!(found.resolved_to, "/people/john-jr/");
4198        assert_eq!(found.candidates, vec!["/people/john-sr/".to_string()]);
4199    }
4200
4201    #[tokio::test]
4202    async fn unambiguous_body_wikilink_reports_nothing() {
4203        let index = Arc::new(WikilinkIndex::new());
4204        index.rebuild(&[
4205            wikilink_note("/people/john/", "John Doe", "john"),
4206            wikilink_note("/notes/family/", "Family", "family"),
4207        ]);
4208
4209        let result = render_with_wikilinks(
4210            "See [[John Doe]] here.",
4211            "/notes/family/",
4212            None,
4213            Some(index),
4214        )
4215        .await;
4216        assert!(result.ambiguous_wikilinks.is_empty());
4217    }
4218
4219    #[tokio::test]
4220    async fn wikilink_ambiguity_is_empty_without_an_index() {
4221        // CLI / QuickLook renders have no repo context at all.
4222        let result = render_result("See [[Anyone]] here.").await;
4223        assert!(result.ambiguous_wikilinks.is_empty());
4224    }
4225
4226    #[test]
4227    fn extract_metadata_from_file_returns_relationships() {
4228        let mut file = NamedTempFile::new().unwrap();
4229        writeln!(
4230            file,
4231            "---\ntype: person\nborn: 1901-05-02\nrelationships:\n  - type: child\n    from: \"[[Sam Doe]]\"\n---\n# John\n"
4232        )
4233        .unwrap();
4234        let result = extract_metadata_from_file(file.path()).unwrap();
4235        assert_eq!(
4236            result.metadata.get("type"),
4237            Some(&serde_json::Value::String("person".to_string()))
4238        );
4239        assert_eq!(result.relationships.len(), 1);
4240        assert_eq!(result.relationships[0].rel_type, "child");
4241        assert_eq!(result.relationships[0].from.as_deref(), Some("[[Sam Doe]]"));
4242    }
4243
4244    #[test]
4245    fn sentence_terminator_basic_cases() {
4246        assert_eq!(count_sentence_terminators(""), (0, false));
4247        assert_eq!(count_sentence_terminators("Hello."), (1, true));
4248        assert_eq!(count_sentence_terminators("Hi! How are you?"), (2, true));
4249        // Ellipsis counts once.
4250        assert_eq!(count_sentence_terminators("Wait..."), (1, true));
4251        // Mid-sentence period not followed by whitespace shouldn't count.
4252        assert_eq!(count_sentence_terminators("v1.2.3 is out."), (1, true));
4253        // Missing trailing terminator.
4254        assert_eq!(count_sentence_terminators("No ending here"), (0, false));
4255    }
4256
4257    #[tokio::test]
4258    async fn readability_counts_simple_paragraph() {
4259        let md = "The cat sat on the mat. The dog ran away.";
4260        let result = render_result(md).await;
4261        assert_eq!(result.word_count, 10);
4262        assert_eq!(result.sentence_count, 2);
4263        // Nine one-syllable words plus "away" (a-way, 2 syllables).
4264        assert_eq!(result.syllable_count, 11);
4265    }
4266
4267    #[tokio::test]
4268    async fn readability_heading_without_terminator_bumps_sentence() {
4269        let md = "# Introduction\n\nHello world.";
4270        let result = render_result(md).await;
4271        assert_eq!(result.word_count, 3);
4272        // Heading ("Introduction") + "Hello world." = 2 sentences.
4273        assert_eq!(result.sentence_count, 2);
4274    }
4275
4276    #[tokio::test]
4277    async fn readability_excludes_code_blocks() {
4278        let md = "Some prose here.\n\n```rust\nfn main() { println!(\"hi\"); }\n```\n";
4279        let result = render_result(md).await;
4280        assert_eq!(result.word_count, 3);
4281        assert_eq!(result.sentence_count, 1);
4282    }
4283
4284    #[tokio::test]
4285    async fn readability_empty_document_has_zero_counts() {
4286        let result = render_result("").await;
4287        assert_eq!(result.word_count, 0);
4288        assert_eq!(result.sentence_count, 0);
4289        assert_eq!(result.syllable_count, 0);
4290    }
4291
4292    // ---- task list rendering -------------------------------------------------
4293
4294    /// The `<li>` body a task renders to, checkbox included, for exact-match
4295    /// assertions that stay readable.
4296    fn task_body(html: &str) -> String {
4297        let start = html.find("<li>").expect("a list item") + "<li>".len();
4298        let end = html.find("</li>").expect("a closed list item");
4299        html[start..end].to_string()
4300    }
4301
4302    #[tokio::test]
4303    async fn canceled_marker_renders_a_checkbox_and_a_status_class() {
4304        // Replaces the old bare `<s>` hack: the class is what the theme styles,
4305        // and it sits on the text so a canceled parent does not strike out its
4306        // own subtasks.
4307        for md in ["- [-] canceled task", "* [-] canceled task"] {
4308            assert_eq!(
4309                task_body(&render_markdown(md).await),
4310                concat!(
4311                    r#"<input type="checkbox" class="mbr-task-check" id="mbr-task-1" "#,
4312                    r#"data-mbr-task-line="1" data-mbr-task-status="canceled" disabled>"#,
4313                    r#"<span class="mbr-task-text mbr-task-canceled">canceled task</span>"#
4314                ),
4315                "for {md:?}"
4316            );
4317        }
4318    }
4319
4320    #[tokio::test]
4321    async fn moved_marker_is_canceled_and_shows_its_destination_date() {
4322        let html = render_markdown("- [>] moved along > 2026-08-04").await;
4323        assert_eq!(
4324            task_body(&html),
4325            concat!(
4326                r#"<input type="checkbox" class="mbr-task-check" id="mbr-task-1" "#,
4327                r#"data-mbr-task-line="1" data-mbr-task-status="canceled" disabled>"#,
4328                r#"<span class="mbr-task-text mbr-task-canceled">moved along</span>"#,
4329                r#" <time class="mbr-task-moved" datetime="2026-08-04">Aug 4</time>"#
4330            )
4331        );
4332    }
4333
4334    #[tokio::test]
4335    async fn unchecked_and_checked_markers_carry_their_status() {
4336        let open = render_markdown("- [ ] unchecked item").await;
4337        assert!(
4338            open.contains(r#"data-mbr-task-status="open" disabled>"#),
4339            "{open}"
4340        );
4341        assert!(!open.contains(" checked"), "{open}");
4342
4343        let done = render_markdown("- [x] checked item").await;
4344        assert!(
4345            done.contains(r#"data-mbr-task-status="done" checked disabled>"#),
4346            "{done}"
4347        );
4348    }
4349
4350    #[tokio::test]
4351    async fn task_text_is_html_escaped() {
4352        let html = render_markdown("- [-] special chars: & < > \"").await;
4353        assert!(html.contains("special chars: &amp; &lt; &gt;"), "{html}");
4354    }
4355
4356    #[tokio::test]
4357    async fn checkboxes_are_inert_in_every_mode() {
4358        // Interactivity is turned on by the frontend, not by the renderer, so
4359        // a static build and a served page emit identical markup.
4360        for server_mode in [false, true] {
4361            let html = render_markdown_with_mode("- [ ] a task", server_mode).await;
4362            assert!(html.contains(" disabled>"), "server_mode={server_mode}");
4363        }
4364    }
4365
4366    #[tokio::test]
4367    async fn annotations_render_as_chips_instead_of_literal_text() {
4368        let html = render_markdown(
4369            "- [ ] write the report !!! #work @due(2026-08-05) @done(2026-08-04 12:11 PM)",
4370        )
4371        .await;
4372        assert_eq!(
4373            task_body(&html),
4374            concat!(
4375                r#"<input type="checkbox" class="mbr-task-check" id="mbr-task-1" "#,
4376                r#"data-mbr-task-line="1" data-mbr-task-status="open" disabled>"#,
4377                r#"<span class="mbr-task-text">write the report</span>"#,
4378                r#" <span class="mbr-task-pri mbr-task-pri-urgent" role="img" "#,
4379                r#"aria-label="Urgent priority" title="Urgent priority"></span>"#,
4380                r#" <span class="mbr-task-tag">#work</span>"#,
4381                r#" <time class="mbr-task-due" datetime="2026-08-05">Aug 5</time>"#,
4382                r#" <time class="mbr-task-completed" datetime="2026-08-04T12:11">Aug 4, 12:11 PM</time>"#
4383            )
4384        );
4385    }
4386
4387    #[tokio::test]
4388    async fn a_task_with_no_annotations_emits_no_chips() {
4389        let html = render_markdown("- [ ] plain task").await;
4390        assert_eq!(
4391            task_body(&html),
4392            concat!(
4393                r#"<input type="checkbox" class="mbr-task-check" id="mbr-task-1" "#,
4394                r#"data-mbr-task-line="1" data-mbr-task-status="open" disabled>"#,
4395                r#"<span class="mbr-task-text">plain task</span>"#
4396            )
4397        );
4398    }
4399
4400    /// The hard case: annotations arrive as text runs interleaved with inline
4401    /// formatting events, so they cannot be stripped from one flat string.
4402    #[tokio::test]
4403    async fn inline_formatting_survives_annotation_stripping() {
4404        let html = render_markdown("- [ ] fix **this** and *that* #bug").await;
4405        assert!(
4406            html.contains(
4407                r#"<span class="mbr-task-text">fix <strong>this</strong> and <em>that</em></span>"#
4408            ),
4409            "inline formatting and its spacing must survive: {html}"
4410        );
4411        assert!(
4412            html.contains(r#"<span class="mbr-task-tag">#bug</span>"#),
4413            "{html}"
4414        );
4415    }
4416
4417    #[tokio::test]
4418    async fn links_inside_a_task_keep_working() {
4419        let html = render_markdown("- [ ] read [the guide](guide.md) !! @due(2026-08-05)").await;
4420        assert!(
4421            html.contains(r#"<a href="../guide/">the guide</a>"#),
4422            "{html}"
4423        );
4424        assert!(html.contains("mbr-task-pri-high"), "{html}");
4425        assert!(!html.contains("@due("), "{html}");
4426    }
4427
4428    /// A trailing `< YYYY-MM-DD` says where a task came from. Nothing surfaces
4429    /// it, so it is stripped and dropped.
4430    #[tokio::test]
4431    async fn moved_from_marker_is_stripped_without_a_chip() {
4432        let html = render_markdown("- [ ] carried over < 2026-08-01").await;
4433        assert!(html.contains(">carried over</span>"), "{html}");
4434        assert!(!html.contains("2026-08-01"), "{html}");
4435    }
4436
4437    #[tokio::test]
4438    async fn nested_subtasks_each_get_their_own_line_number() {
4439        let html = render_markdown("- [ ] parent\n\t- [ ] child one\n\t- [x] child two").await;
4440        for line in 1..=3 {
4441            assert!(
4442                html.contains(&format!(r#"data-mbr-task-line="{line}""#)),
4443                "missing line {line}: {html}"
4444            );
4445        }
4446        // The parent's text span must close before its subtask list, or the
4447        // chips would render underneath the children.
4448        assert!(
4449            html.contains("<span class=\"mbr-task-text\">parent</span>\n<ul>"),
4450            "the parent's text span must close before its subtask list: {html}"
4451        );
4452    }
4453
4454    #[tokio::test]
4455    async fn a_marker_inside_a_fenced_code_block_is_left_alone() {
4456        let html = render_result("```\n- [-] not a checkbox\n- [ ] nor this\n```\n")
4457            .await
4458            .html;
4459        assert!(!html.contains("mbr-task-check"), "{html}");
4460        assert!(html.contains("- [-] not a checkbox"), "{html}");
4461        assert!(html.contains("- [ ] nor this"), "{html}");
4462    }
4463
4464    #[tokio::test]
4465    async fn a_bracket_marker_outside_a_list_item_is_not_a_task() {
4466        // The old renderer turned any text starting with `[-] ` into a
4467        // checkbox, including ordinary prose.
4468        let html = render_result("[-] this is just a sentence\n").await.html;
4469        assert!(!html.contains("mbr-task-check"), "{html}");
4470        assert!(html.contains("[-] this is just a sentence"), "{html}");
4471    }
4472
4473    // ---- task line numbers ---------------------------------------------------
4474
4475    /// The 1-based lines advertised by the rendered checkboxes, in order.
4476    fn rendered_task_lines(html: &str) -> Vec<u32> {
4477        const ATTR: &str = "data-mbr-task-line=\"";
4478        html.match_indices(ATTR)
4479            .map(|(at, _)| {
4480                let rest = &html[at + ATTR.len()..];
4481                let end = rest.find('"').expect("unterminated attribute");
4482                rest[..end].parse().expect("numeric line")
4483            })
4484            .collect()
4485    }
4486
4487    /// Line numbers must survive whatever sits between the tasks — headings,
4488    /// fences, blank lines, frontmatter — because they are derived from byte
4489    /// offsets rather than from counting events.
4490    #[tokio::test]
4491    async fn task_line_numbers_survive_intervening_blocks() {
4492        let md = concat!(
4493            "---\n",           // 1
4494            "title: T\n",      // 2
4495            "---\n",           // 3
4496            "\n",              // 4
4497            "# Heading\n",     // 5
4498            "\n",              // 6
4499            "- [ ] first\n",   // 7
4500            "\n",              // 8
4501            "```js\n",         // 9
4502            "// - [ ] fake\n", // 10
4503            "```\n",           // 11
4504            "\n",              // 12
4505            "Some prose.\n",   // 13
4506            "\n",              // 14
4507            "- [x] second\n",  // 15
4508            "- [-] third\n",   // 16
4509        );
4510        let html = render_result(md).await.html;
4511        assert_eq!(rendered_task_lines(&html), vec![7, 15, 16]);
4512        assert_eq!(
4513            rendered_task_lines(&html),
4514            crate::tasks::scan_source_tasks(md)
4515                .into_iter()
4516                .map(|task| task.line)
4517                .collect::<Vec<_>>(),
4518            "the renderer and the task index must agree about line numbers"
4519        );
4520    }
4521
4522    #[tokio::test]
4523    async fn crlf_line_endings_do_not_shift_task_line_numbers() {
4524        let md = "- [ ] first\r\n\r\nprose\r\n\r\n- [x] second\r\n";
4525        let html = render_result(md).await.html;
4526        assert_eq!(rendered_task_lines(&html), vec![1, 5]);
4527    }
4528
4529    /// The incomplete-block pass runs after the task rewrite, so it sees the
4530    /// annotation-stripped display text and must still wrap the item.
4531    #[tokio::test]
4532    async fn incomplete_markers_still_fire_inside_a_task() {
4533        let html = render_markdown_marked("- [ ] TODO: write it up #docs", &["TODO"]).await;
4534        assert!(html.contains(INCOMPLETE_SPAN_PREFIX), "{html}");
4535        assert!(html.contains("TODO: write it up"), "{html}");
4536        assert!(
4537            html.contains(r#"<span class="mbr-task-tag">#docs</span>"#),
4538            "{html}"
4539        );
4540        // One open, one close: the two passes must not interleave their spans
4541        // into overlapping tags.
4542        assert_eq!(incomplete_span_count(&html), 1, "{html}");
4543        // Both deep links exist for the same line, and they do not collide.
4544        assert!(html.contains(r#"id="mbr-task-1""#), "{html}");
4545        assert!(html.contains(r#"id="mbr-marker-1""#), "{html}");
4546    }
4547
4548    /// The parser is handed the *wikilink-transformed* source, not the file on
4549    /// disk, so `transform_wikilinks` sits between the bytes on disk and the
4550    /// offsets the line numbers come from. For an ordinary single-line wikilink
4551    /// it rewrites within the line and the numbers are unaffected.
4552    #[tokio::test]
4553    async fn wikilinks_on_a_task_line_do_not_shift_its_line_number() {
4554        let sources: HashSet<String> = ["Tags".to_string()].into_iter().collect();
4555        let md = concat!(
4556            "- [ ] read about [[Tags:rust]] #study\n",
4557            "- [ ] and [[Tags:async]] too\n",
4558            "\n",
4559            "- [x] last one\n",
4560        );
4561        let html = render_markdown_with_tags(md, sources).await;
4562        assert_eq!(rendered_task_lines(&html), vec![1, 2, 4]);
4563        assert!(html.contains("href=\"/tags/rust/\""), "{html}");
4564        assert!(
4565            html.contains(r#"<span class="mbr-task-tag">#study</span>"#),
4566            "{html}"
4567        );
4568    }
4569
4570    /// A `[[Source:value]]` whose brackets straddle a line break is not a
4571    /// wikilink, so the substitution cannot swallow the newline and the lines
4572    /// below keep their numbers.
4573    ///
4574    /// This used to be a pinned known limitation: the transformed source came
4575    /// out a line shorter than the file on disk, every later task advertised a
4576    /// line number one too small, and a line patch aimed at one of those numbers
4577    /// would have edited the wrong line.
4578    #[tokio::test]
4579    async fn a_multi_line_tag_wikilink_does_not_shift_later_line_numbers() {
4580        let sources: HashSet<String> = ["Tags".to_string()].into_iter().collect();
4581        let md = "- [ ] see [[Tags:\nrust]] here\n- [x] second\n";
4582
4583        let expected: Vec<u32> = crate::tasks::scan_source_tasks(md)
4584            .into_iter()
4585            .map(|task| task.line)
4586            .collect();
4587        assert_eq!(expected, vec![1, 3]);
4588
4589        let html = render_markdown_with_tags(md, sources).await;
4590        assert_eq!(
4591            rendered_task_lines(&html),
4592            expected,
4593            "the renderer and the task index must agree about line numbers"
4594        );
4595        // ...and it was not rewritten into a tag link on the way through.
4596        assert!(!html.contains("/tags/rust/"), "{html}");
4597    }
4598
4599    #[test]
4600    fn line_index_maps_offsets_to_one_based_lines() {
4601        //             0123 456 78
4602        let index = LineIndex::build("ab\nc\n\nd");
4603        assert_eq!(index.line_of(0), 1);
4604        assert_eq!(index.line_of(2), 1); // the newline itself ends line 1
4605        assert_eq!(index.line_of(3), 2);
4606        assert_eq!(index.line_of(5), 3); // the empty line
4607        assert_eq!(index.line_of(6), 4);
4608        // Past the end is still the last line rather than a panic.
4609        assert_eq!(index.line_of(999), 4);
4610
4611        assert_eq!(LineIndex::build("").line_of(0), 1);
4612    }
4613
4614    #[test]
4615    fn split_extended_marker_requires_whitespace_after_the_box() {
4616        assert_eq!(
4617            split_extended_marker("[-] canceled"),
4618            Some((TaskStatus::Canceled, "canceled"))
4619        );
4620        assert_eq!(
4621            split_extended_marker("[>]\tmoved"),
4622            Some((TaskStatus::Canceled, "moved"))
4623        );
4624        assert_eq!(
4625            split_extended_marker("[-]"),
4626            Some((TaskStatus::Canceled, ""))
4627        );
4628        for text in ["[-]x", "[x] done", "[ ] open", "[?] what", "prose"] {
4629            assert_eq!(split_extended_marker(text), None, "for {text:?}");
4630        }
4631    }
4632
4633    #[tokio::test]
4634    async fn test_yaml_frontmatter() {
4635        let md = "---\ntitle: Test Title\n---\n\n# Heading";
4636        let mut file = NamedTempFile::new().unwrap();
4637        file.write_all(md.as_bytes()).unwrap();
4638        let path = file.path().to_path_buf();
4639        let root = path.parent().unwrap().to_path_buf();
4640        let config = LinkTransformConfig {
4641            markdown_extensions: vec!["md".to_string()],
4642            index_file: "index.md".to_string(),
4643            is_index_file: false,
4644            url_depth: None,
4645            current_page_url: String::new(),
4646            markdown_page_probe: None,
4647        };
4648        let result = render(
4649            path,
4650            &root,
4651            100,
4652            config,
4653            false,
4654            false,
4655            HashSet::new(),
4656            ReviewLines::Omit,
4657            false,
4658            &[],
4659            None,
4660        )
4661        .await
4662        .unwrap();
4663        assert_eq!(
4664            result.frontmatter.get("title"),
4665            Some(&serde_json::Value::String("Test Title".to_string()))
4666        );
4667    }
4668
4669    // H1 extraction tests
4670    #[test]
4671    fn test_extract_first_h1_basic() {
4672        let md = "# Hello World\n\nSome content";
4673        let result = extract_first_h1(md);
4674        assert_eq!(result, Some("Hello World".to_string()));
4675    }
4676
4677    #[test]
4678    fn test_extract_first_h1_with_inline_formatting() {
4679        let md = "# Hello **World**\n\nSome content";
4680        let result = extract_first_h1(md);
4681        assert_eq!(result, Some("Hello World".to_string()));
4682    }
4683
4684    #[test]
4685    fn test_extract_first_h1_none_when_no_h1() {
4686        let md = "## This is H2\n\nSome content";
4687        let result = extract_first_h1(md);
4688        assert_eq!(result, None);
4689    }
4690
4691    #[test]
4692    fn test_extract_first_h1_returns_first_only() {
4693        let md = "# First H1\n\n# Second H1";
4694        let result = extract_first_h1(md);
4695        assert_eq!(result, Some("First H1".to_string()));
4696    }
4697
4698    #[test]
4699    fn test_extract_first_h1_empty_doc() {
4700        let md = "";
4701        let result = extract_first_h1(md);
4702        assert_eq!(result, None);
4703    }
4704
4705    #[tokio::test]
4706    async fn test_has_h1_true_when_first_heading_is_h1() {
4707        let md = "# Main Title\n\n## Subsection";
4708        let mut file = NamedTempFile::new().unwrap();
4709        file.write_all(md.as_bytes()).unwrap();
4710        let path = file.path().to_path_buf();
4711        let root = path.parent().unwrap().to_path_buf();
4712        let config = LinkTransformConfig {
4713            markdown_extensions: vec!["md".to_string()],
4714            index_file: "index.md".to_string(),
4715            is_index_file: false,
4716            url_depth: None,
4717            current_page_url: String::new(),
4718            markdown_page_probe: None,
4719        };
4720        let result = render(
4721            path,
4722            &root,
4723            100,
4724            config,
4725            false,
4726            false,
4727            HashSet::new(),
4728            ReviewLines::Omit,
4729            false,
4730            &[],
4731            None,
4732        )
4733        .await
4734        .unwrap();
4735        assert!(result.has_h1);
4736    }
4737
4738    #[tokio::test]
4739    async fn test_has_h1_false_when_first_heading_is_h2() {
4740        let md = "## Subsection\n\n# Late H1";
4741        let mut file = NamedTempFile::new().unwrap();
4742        file.write_all(md.as_bytes()).unwrap();
4743        let path = file.path().to_path_buf();
4744        let root = path.parent().unwrap().to_path_buf();
4745        let config = LinkTransformConfig {
4746            markdown_extensions: vec!["md".to_string()],
4747            index_file: "index.md".to_string(),
4748            is_index_file: false,
4749            url_depth: None,
4750            current_page_url: String::new(),
4751            markdown_page_probe: None,
4752        };
4753        let result = render(
4754            path,
4755            &root,
4756            100,
4757            config,
4758            false,
4759            false,
4760            HashSet::new(),
4761            ReviewLines::Omit,
4762            false,
4763            &[],
4764            None,
4765        )
4766        .await
4767        .unwrap();
4768        assert!(!result.has_h1);
4769    }
4770
4771    #[tokio::test]
4772    async fn test_title_fallback_from_h1() {
4773        // No frontmatter title, but has H1 - should extract title from H1
4774        let md = "# My Document Title\n\nSome content here.";
4775        let mut file = NamedTempFile::new().unwrap();
4776        file.write_all(md.as_bytes()).unwrap();
4777        let path = file.path().to_path_buf();
4778        let root = path.parent().unwrap().to_path_buf();
4779        let config = LinkTransformConfig {
4780            markdown_extensions: vec!["md".to_string()],
4781            index_file: "index.md".to_string(),
4782            is_index_file: false,
4783            url_depth: None,
4784            current_page_url: String::new(),
4785            markdown_page_probe: None,
4786        };
4787        let result = render(
4788            path,
4789            &root,
4790            100,
4791            config,
4792            false,
4793            false,
4794            HashSet::new(),
4795            ReviewLines::Omit,
4796            false,
4797            &[],
4798            None,
4799        )
4800        .await
4801        .unwrap();
4802        assert!(result.has_h1);
4803        assert_eq!(
4804            result.frontmatter.get("title"),
4805            Some(&serde_json::Value::String("My Document Title".to_string()))
4806        );
4807    }
4808
4809    #[tokio::test]
4810    async fn test_frontmatter_title_takes_precedence() {
4811        // Frontmatter title should take precedence over H1
4812        let md = "---\ntitle: Frontmatter Title\n---\n\n# H1 Title";
4813        let mut file = NamedTempFile::new().unwrap();
4814        file.write_all(md.as_bytes()).unwrap();
4815        let path = file.path().to_path_buf();
4816        let root = path.parent().unwrap().to_path_buf();
4817        let config = LinkTransformConfig {
4818            markdown_extensions: vec!["md".to_string()],
4819            index_file: "index.md".to_string(),
4820            is_index_file: false,
4821            url_depth: None,
4822            current_page_url: String::new(),
4823            markdown_page_probe: None,
4824        };
4825        let result = render(
4826            path,
4827            &root,
4828            100,
4829            config,
4830            false,
4831            false,
4832            HashSet::new(),
4833            ReviewLines::Omit,
4834            false,
4835            &[],
4836            None,
4837        )
4838        .await
4839        .unwrap();
4840        assert!(result.has_h1);
4841        assert_eq!(
4842            result.frontmatter.get("title"),
4843            Some(&serde_json::Value::String("Frontmatter Title".to_string()))
4844        );
4845    }
4846
4847    #[tokio::test]
4848    async fn test_no_title_when_no_frontmatter_and_no_h1() {
4849        // No frontmatter and no H1 - should have no title
4850        let md = "## Subsection\n\nSome content.";
4851        let mut file = NamedTempFile::new().unwrap();
4852        file.write_all(md.as_bytes()).unwrap();
4853        let path = file.path().to_path_buf();
4854        let root = path.parent().unwrap().to_path_buf();
4855        let config = LinkTransformConfig {
4856            markdown_extensions: vec!["md".to_string()],
4857            index_file: "index.md".to_string(),
4858            is_index_file: false,
4859            url_depth: None,
4860            current_page_url: String::new(),
4861            markdown_page_probe: None,
4862        };
4863        let result = render(
4864            path,
4865            &root,
4866            100,
4867            config,
4868            false,
4869            false,
4870            HashSet::new(),
4871            ReviewLines::Omit,
4872            false,
4873            &[],
4874            None,
4875        )
4876        .await
4877        .unwrap();
4878        assert!(!result.has_h1);
4879        assert_eq!(result.frontmatter.get("title"), None);
4880    }
4881
4882    // Media embed tests
4883    #[tokio::test]
4884    async fn test_video_embed_from_image_syntax() {
4885        let md = "![My Video](video.mp4)";
4886        let html = render_markdown(md).await;
4887        assert!(html.contains("<video"));
4888        assert!(html.contains("video.mp4"));
4889        assert!(html.contains("<figcaption>"));
4890        assert!(html.contains("My Video"));
4891        assert!(html.contains("</figcaption></figure>"));
4892    }
4893
4894    #[tokio::test]
4895    async fn test_vid_shortcode_apostrophe_path_not_curly_encoded() {
4896        // Smart punctuation curls the apostrophe; the path must be normalized back
4897        // to ASCII so the percent-encoded URL matches the real filename on disk.
4898        let html = render_markdown("{{ vid(path=\"World's Best.mp4\") }}").await;
4899        assert!(html.contains("World%27s%20Best.mp4"), "got: {html}");
4900        assert!(
4901            !html.contains("%E2%80%99"),
4902            "curly apostrophe leaked into URL: {html}"
4903        );
4904    }
4905
4906    #[tokio::test]
4907    async fn test_audio_embed_from_image_syntax() {
4908        let md = "![Episode 1](podcast.mp3)";
4909        let html = render_markdown(md).await;
4910        assert!(html.contains("<audio"));
4911        assert!(html.contains("audio-embed"));
4912        assert!(html.contains("podcast.mp3"));
4913        assert!(html.contains("<figcaption>"));
4914        assert!(html.contains("Episode 1"));
4915        assert!(html.contains("</figcaption></figure>"));
4916    }
4917
4918    #[tokio::test]
4919    async fn test_youtube_embed_from_image_syntax() {
4920        let md = "![Watch this](https://www.youtube-nocookie.com/watch?v=dQw4w9WgXcQ)";
4921        let html = render_markdown(md).await;
4922        assert!(html.contains("youtube-embed"));
4923        assert!(html.contains("youtube-nocookie.com/embed/dQw4w9WgXcQ"));
4924        assert!(html.contains("<figcaption>"));
4925        assert!(html.contains("Watch this"));
4926        assert!(html.contains("</figcaption></figure>"));
4927    }
4928
4929    #[tokio::test]
4930    async fn test_youtube_short_url_embed() {
4931        let md = "![](https://youtu.be/dQw4w9WgXcQ)";
4932        let html = render_markdown(md).await;
4933        assert!(html.contains("youtube-embed"));
4934        assert!(html.contains("youtube-nocookie.com/embed/dQw4w9WgXcQ"));
4935    }
4936
4937    #[tokio::test]
4938    async fn test_pdf_embed_from_image_syntax() {
4939        let md = "![Important Document](report.pdf)";
4940        let html = render_markdown(md).await;
4941        assert!(html.contains("pdf-embed"));
4942        // URL is transformed for trailing-slash convention (../report.pdf for non-index files)
4943        assert!(
4944            html.contains(r#"data="../report.pdf""#),
4945            "PDF URL should be transformed. Got: {}",
4946            html
4947        );
4948        assert!(html.contains(r#"type="application/pdf""#));
4949        assert!(html.contains("data-pdf-fallback"));
4950        assert!(html.contains("<figcaption>"));
4951        assert!(html.contains("Important Document"));
4952        assert!(html.contains("</figcaption></figure>"));
4953    }
4954
4955    #[tokio::test]
4956    async fn test_pdf_embed_with_path() {
4957        let md = "![](docs/manual.pdf)";
4958        let html = render_markdown(md).await;
4959        assert!(html.contains("pdf-embed"));
4960        // URL is transformed for trailing-slash convention (../docs/manual.pdf for non-index files)
4961        assert!(
4962            html.contains(r#"data="../docs/manual.pdf""#),
4963            "PDF URL should be transformed. Got: {}",
4964            html
4965        );
4966    }
4967
4968    #[tokio::test]
4969    async fn test_regular_image_not_converted() {
4970        let md = "![Alt text](photo.jpg)";
4971        let html = render_markdown(md).await;
4972        assert!(html.contains("<img"));
4973        assert!(html.contains("photo.jpg"));
4974        assert!(!html.contains("<video"));
4975        assert!(!html.contains("<audio"));
4976        assert!(!html.contains("pdf-embed"));
4977    }
4978
4979    #[tokio::test]
4980    async fn test_multiple_media_types_in_document() {
4981        let md = r#"
4982# My Media
4983
4984![Video](clip.mp4)
4985
4986![Audio](song.mp3)
4987
4988![PDF](doc.pdf)
4989
4990![Image](photo.png)
4991"#;
4992        let html = render_markdown(md).await;
4993        assert!(html.contains("<video"));
4994        assert!(html.contains("<audio"));
4995        assert!(html.contains("pdf-embed"));
4996        assert!(html.contains("<img"));
4997    }
4998
4999    #[tokio::test]
5000    async fn test_vid_shortcode() {
5001        let md = r#"{{ vid(path="test/video.mp4") }}"#;
5002        let html = render_markdown(md).await;
5003        println!("Output HTML: {}", html);
5004        assert!(html.contains("<video"), "Should contain video element");
5005        assert!(
5006            html.contains("/videos/test/video.mp4"),
5007            "Should contain video path"
5008        );
5009    }
5010
5011    #[tokio::test]
5012    async fn test_vid_shortcode_with_spaces() {
5013        let md = r#"{{ vid(path="Eric Jones/Eric Jones - Metal 3.mp4")}}"#;
5014        let html = render_markdown(md).await;
5015        println!("Output HTML: {}", html);
5016        assert!(html.contains("<video"), "Should contain video element");
5017        assert!(
5018            html.contains("/videos/Eric%20Jones"),
5019            "Should contain URL-encoded path"
5020        );
5021    }
5022
5023    // Link transformation tests
5024    #[tokio::test]
5025    async fn test_link_transformation_regular_markdown() {
5026        // Regular markdown file (not index) - links get ../ prefix
5027        let md = "[Other Doc](other.md)";
5028        let html = render_markdown_with_config(md, false, HashSet::new()).await;
5029        assert!(
5030            html.contains(r#"href="../other/""#),
5031            "Regular markdown should transform other.md to ../other/. Got: {}",
5032            html
5033        );
5034    }
5035
5036    #[tokio::test]
5037    async fn test_link_transformation_index_file() {
5038        // Index file - links don't get ../ prefix
5039        let md = "[Other Doc](other.md)";
5040        let html = render_markdown_with_config(md, true, HashSet::new()).await;
5041        assert!(
5042            html.contains(r#"href="other/""#),
5043            "Index file should transform other.md to other/. Got: {}",
5044            html
5045        );
5046    }
5047
5048    #[tokio::test]
5049    async fn test_link_transformation_preserves_absolute_urls() {
5050        let md = "[External](https://example.com)";
5051        let html = render_markdown(md).await;
5052        assert!(
5053            html.contains(r#"href="https://example.com""#),
5054            "Absolute URLs should remain unchanged"
5055        );
5056    }
5057
5058    #[tokio::test]
5059    async fn test_link_transformation_with_anchor() {
5060        let md = "[Section](other.md#section)";
5061        let html = render_markdown_with_config(md, false, HashSet::new()).await;
5062        assert!(
5063            html.contains(r#"href="../other/#section""#),
5064            "Links with anchors should transform correctly. Got: {}",
5065            html
5066        );
5067    }
5068
5069    #[tokio::test]
5070    async fn test_image_transformation_regular_markdown() {
5071        // Regular images (not media embeds) should also be transformed
5072        let md = "![Alt](images/photo.jpg)";
5073        let html = render_markdown_with_config(md, false, HashSet::new()).await;
5074        assert!(
5075            html.contains(r#"src="../images/photo.jpg""#),
5076            "Image URLs should be transformed. Got: {}",
5077            html
5078        );
5079    }
5080
5081    #[tokio::test]
5082    async fn test_image_transformation_index_file() {
5083        let md = "![Alt](images/photo.jpg)";
5084        let html = render_markdown_with_config(md, true, HashSet::new()).await;
5085        assert!(
5086            html.contains(r#"src="images/photo.jpg""#),
5087            "Index file image URLs shouldn't get ../. Got: {}",
5088            html
5089        );
5090    }
5091
5092    // Media embed URL transformation tests
5093    #[tokio::test]
5094    async fn test_video_embed_url_transformation() {
5095        // Video embeds in regular markdown files should get ../ prefix
5096        let md = "![My Video](video.mp4)";
5097        let html = render_markdown_with_config(md, false, HashSet::new()).await;
5098        assert!(
5099            html.contains("../video.mp4"),
5100            "Video URLs should be transformed with ../. Got: {}",
5101            html
5102        );
5103    }
5104
5105    #[tokio::test]
5106    async fn test_video_embed_url_transformation_index_file() {
5107        // Video embeds in index files should NOT get ../ prefix
5108        let md = "![My Video](video.mp4)";
5109        let html = render_markdown_with_config(md, true, HashSet::new()).await;
5110        assert!(
5111            !html.contains("../video.mp4"),
5112            "Index file video URLs shouldn't get ../. Got: {}",
5113            html
5114        );
5115        assert!(
5116            html.contains("video.mp4"),
5117            "Video URL should be present. Got: {}",
5118            html
5119        );
5120    }
5121
5122    #[tokio::test]
5123    async fn test_audio_embed_url_transformation() {
5124        // Audio embeds in regular markdown files should get ../ prefix
5125        let md = "![Podcast](episode.mp3)";
5126        let html = render_markdown_with_config(md, false, HashSet::new()).await;
5127        assert!(
5128            html.contains("../episode.mp3"),
5129            "Audio URLs should be transformed with ../. Got: {}",
5130            html
5131        );
5132    }
5133
5134    #[tokio::test]
5135    async fn test_pdf_embed_url_transformation() {
5136        // PDF embeds in regular markdown files should get ../ prefix
5137        let md = "![Document](report.pdf)";
5138        let html = render_markdown_with_config(md, false, HashSet::new()).await;
5139        assert!(
5140            html.contains("../report.pdf"),
5141            "PDF URLs should be transformed with ../. Got: {}",
5142            html
5143        );
5144    }
5145
5146    #[tokio::test]
5147    async fn test_pdf_embed_url_transformation_index_file() {
5148        // PDF embeds in index files should NOT get ../ prefix
5149        let md = "![Document](report.pdf)";
5150        let html = render_markdown_with_config(md, true, HashSet::new()).await;
5151        assert!(
5152            !html.contains("../report.pdf"),
5153            "Index file PDF URLs shouldn't get ../. Got: {}",
5154            html
5155        );
5156        assert!(
5157            html.contains("report.pdf"),
5158            "PDF URL should be present. Got: {}",
5159            html
5160        );
5161    }
5162
5163    #[tokio::test]
5164    async fn test_media_embed_peer_file_transformation() {
5165        // Test the specific bug case: peer file in same folder as markdown
5166        // Markdown: docs/guide.md references peer-video.mp4 (docs/peer-video.mp4)
5167        // When served at /docs/guide/, browser sees ../peer-video.mp4 → /docs/peer-video.mp4 (correct!)
5168        let md = "![](peer-video.mp4)";
5169        let html = render_markdown_with_config(md, false, HashSet::new()).await;
5170        assert!(
5171            html.contains("../peer-video.mp4"),
5172            "Peer file video should get ../ prefix. Got: {}",
5173            html
5174        );
5175    }
5176
5177    #[tokio::test]
5178    async fn test_media_embed_explicit_relative_path() {
5179        // Test ./file.mp4 syntax also gets transformed correctly
5180        let md = "![](./peer-video.mp4)";
5181        let html = render_markdown_with_config(md, false, HashSet::new()).await;
5182        assert!(
5183            html.contains("../peer-video.mp4"),
5184            "./peer-video.mp4 should transform to ../peer-video.mp4. Got: {}",
5185            html
5186        );
5187    }
5188
5189    // Section attributes tests
5190    #[tokio::test]
5191    async fn test_section_attrs_with_id() {
5192        // Test that --- {#id} applies ID to the next section
5193        let md = "First section\n\n--- {#intro}\n\nSecond section";
5194        let html = render_markdown(md).await;
5195        assert!(
5196            html.contains(r#"<section id="intro">"#),
5197            "Section should have id='intro'. Got: {}",
5198            html
5199        );
5200    }
5201
5202    #[tokio::test]
5203    async fn test_section_attrs_with_class() {
5204        // Test that --- {.highlight} applies class to the next section
5205        let md = "First section\n\n--- {.highlight}\n\nSecond section";
5206        let html = render_markdown(md).await;
5207        assert!(
5208            html.contains(r#"<section class="highlight">"#),
5209            "Section should have class='highlight'. Got: {}",
5210            html
5211        );
5212    }
5213
5214    #[tokio::test]
5215    async fn test_section_attrs_with_multiple_classes() {
5216        // Test multiple classes
5217        let md = "First section\n\n--- {.slide .center}\n\nSecond section";
5218        let html = render_markdown(md).await;
5219        assert!(
5220            html.contains(r#"<section class="slide center">"#),
5221            "Section should have class='slide center'. Got: {}",
5222            html
5223        );
5224    }
5225
5226    #[tokio::test]
5227    async fn test_section_attrs_with_data_attributes() {
5228        // Test data attributes
5229        let md = "First section\n\n--- {data-transition=\"slide\"}\n\nSecond section";
5230        let html = render_markdown(md).await;
5231        assert!(
5232            html.contains(r#"data-transition="slide""#),
5233            "Section should have data-transition='slide'. Got: {}",
5234            html
5235        );
5236    }
5237
5238    #[tokio::test]
5239    async fn test_section_attrs_mixed() {
5240        // Test ID, class, and data attribute together
5241        let md = "First section\n\n--- {#main .highlight data-bg=\"blue\"}\n\nSecond section";
5242        let html = render_markdown(md).await;
5243        assert!(
5244            html.contains(r#"id="main""#),
5245            "Section should have id='main'. Got: {}",
5246            html
5247        );
5248        assert!(
5249            html.contains(r#"class="highlight""#),
5250            "Section should have class='highlight'. Got: {}",
5251            html
5252        );
5253        assert!(
5254            html.contains(r#"data-bg="blue""#),
5255            "Section should have data-bg='blue'. Got: {}",
5256            html
5257        );
5258    }
5259
5260    #[tokio::test]
5261    async fn test_section_attrs_multiple_rules() {
5262        // Test multiple rules with attrs
5263        let md = "Section 0\n\n--- {#one}\n\nSection 1\n\n--- {#two}\n\nSection 2";
5264        let html = render_markdown(md).await;
5265        assert!(
5266            html.contains(r#"<section id="one">"#),
5267            "First rule section should have id='one'. Got: {}",
5268            html
5269        );
5270        assert!(
5271            html.contains(r#"<section id="two">"#),
5272            "Second rule section should have id='two'. Got: {}",
5273            html
5274        );
5275    }
5276
5277    #[tokio::test]
5278    async fn test_plain_rule_still_works() {
5279        // Test that plain --- without attrs still creates a section
5280        let md = "First section\n\n---\n\nSecond section";
5281        let html = render_markdown(md).await;
5282        // Should have at least 2 sections (one before and one after the rule)
5283        let section_count = html.matches("<section>").count();
5284        assert!(
5285            section_count >= 1,
5286            "Plain rule should create sections. Got: {}",
5287            html
5288        );
5289        assert!(
5290            html.contains("<hr />"),
5291            "Should contain <hr /> divider. Got: {}",
5292            html
5293        );
5294    }
5295
5296    #[tokio::test]
5297    async fn test_em_dash_with_non_attrs_text() {
5298        // Test that --- followed by text that isn't attrs is rendered normally
5299        // This becomes paragraph with em dash + text (not transformed to Rule)
5300        let md = "Some text\n\n--- not attrs\n\nMore text";
5301        let html = render_markdown(md).await;
5302        // Should NOT have a <hr /> since it's not a valid rule pattern
5303        // The em dash paragraph should be preserved as text
5304        assert!(
5305            html.contains("—"),
5306            "Em dash should be preserved. Got: {}",
5307            html
5308        );
5309    }
5310
5311    #[tokio::test]
5312    async fn test_section_empty_attrs() {
5313        // Test that --- {} creates a section without any attributes
5314        let md = "First section\n\n--- {}\n\nSecond section";
5315        let html = render_markdown(md).await;
5316        // Should have a plain section (no id, class, or attrs)
5317        // The section should close and reopen with just <section>
5318        assert!(
5319            html.contains("<section>"),
5320            "Empty attrs should create plain section. Got: {}",
5321            html
5322        );
5323        assert!(
5324            html.contains("<hr />"),
5325            "Should contain <hr /> divider. Got: {}",
5326            html
5327        );
5328    }
5329
5330    #[tokio::test]
5331    async fn test_section_attrs_with_whitespace() {
5332        // Test that whitespace inside braces is handled
5333        let md = "First section\n\n--- {  #intro  .highlight  }\n\nSecond section";
5334        let html = render_markdown(md).await;
5335        assert!(
5336            html.contains(r#"id="intro""#),
5337            "Whitespace should not affect ID parsing. Got: {}",
5338            html
5339        );
5340        assert!(
5341            html.contains(r#"class="highlight""#),
5342            "Whitespace should not affect class parsing. Got: {}",
5343            html
5344        );
5345    }
5346
5347    #[tokio::test]
5348    async fn test_section_attrs_curly_quotes() {
5349        // Test curly quotes from smart punctuation (pulldown-cmark converts " to "")
5350        // Build the attrs string with explicit curly quotes
5351        let md = "First section\n\n--- {data-x=\u{201C}value\u{201D}}\n\nSecond section";
5352        let html = render_markdown(md).await;
5353        // The curly quotes should be normalized to straight quotes in output
5354        assert!(
5355            html.contains(r#"data-x="value""#),
5356            "Curly quotes should be normalized. Got: {}",
5357            html
5358        );
5359    }
5360
5361    #[tokio::test]
5362    async fn test_section_attrs_html_escaping() {
5363        // Test that attribute values with HTML special chars are escaped
5364        // Note: Can't use <script> directly as pulldown-cmark interprets it as HTML
5365        // Use & and ' which need escaping but don't break markdown parsing
5366        let md = "First section\n\n--- {data-val=\"a & b\"}\n\nSecond section";
5367        let html = render_markdown(md).await;
5368        // The & should be escaped to &amp;
5369        assert!(
5370            html.contains("&amp;"),
5371            "HTML special chars should be escaped. Got: {}",
5372            html
5373        );
5374        assert!(
5375            html.contains(r#"data-val="a &amp; b""#),
5376            "Value should have escaped &. Got: {}",
5377            html
5378        );
5379    }
5380
5381    // ============ Raw HTML URL tests ============
5382
5383    /// The `href` a markdown link to `path` gets on a non-index page.
5384    fn link_href(path: &str, url_depth: Option<usize>) -> String {
5385        let html = render_with_depth(&format!("[x]({path})"), url_depth);
5386        let start = html.find("href=\"").expect("link href") + 6;
5387        let len = html[start..].find('"').expect("closing quote");
5388        html[start..start + len].to_string()
5389    }
5390
5391    #[test]
5392    fn raw_html_block_img_src_resolves_like_a_markdown_image() {
5393        let md = "<p align=\"center\">\n    <img src=\"docs/images/x.png\" width=\"900\" alt=\"hero\">\n</p>\n";
5394        for depth in [None, Some(0), Some(2)] {
5395            let expected = image_src("docs/images/x.png", depth);
5396            let html = render_with_depth(md, depth);
5397            assert!(
5398                html.contains(&format!(r#"<img src="{expected}" width="900" alt="hero">"#)),
5399                "depth {depth:?}: expected src {expected}. Got: {html}"
5400            );
5401        }
5402        // Spelled out once, so the comparison cannot pass vacuously.
5403        assert!(render_with_depth(md, None).contains(r#"src="../docs/images/x.png""#));
5404    }
5405
5406    #[test]
5407    fn raw_html_inline_img_in_a_paragraph() {
5408        let html = render_with_depth("Look: <img src=\"pic.png\" alt=\"p\"> here.", None);
5409        assert!(html.contains(r#"<img src="../pic.png" alt="p">"#), "{html}");
5410    }
5411
5412    #[test]
5413    fn raw_html_anchor_href_resolves_like_a_markdown_link() {
5414        for depth in [None, Some(2)] {
5415            for path in ["other.md", "docs/guide.md", "/abs/page.md", "pic.png"] {
5416                let expected = link_href(path, depth);
5417                let html = render_with_depth(&format!("<a href=\"{path}\">x</a>"), depth);
5418                assert!(
5419                    html.contains(&format!(r#"<a href="{expected}">"#)),
5420                    "depth {depth:?}, {path} should become {expected}. Got: {html}"
5421                );
5422            }
5423        }
5424        assert!(
5425            render_with_depth("<a href=\"other.md\">x</a>", None).contains(r#"href="../other/""#)
5426        );
5427    }
5428
5429    #[test]
5430    fn raw_html_single_quotes_and_poster() {
5431        let html = render_with_depth(
5432            "<video src='clip.mp4' poster='still.jpg' controls></video>\n",
5433            None,
5434        );
5435        assert!(
5436            html.contains("<video src='../clip.mp4' poster='../still.jpg' controls>"),
5437            "{html}"
5438        );
5439    }
5440
5441    #[test]
5442    fn raw_html_entities_round_trip() {
5443        let html = render_with_depth("<a href=\"page.html?a=1&amp;b=2\">x</a>", None);
5444        assert!(
5445            html.contains(r#"href="../page.html?a=1&amp;b=2""#),
5446            "{html}"
5447        );
5448    }
5449
5450    #[test]
5451    fn raw_html_left_alone_byte_for_byte() {
5452        for chunk in [
5453            "<img src=\"https://example.com/a.png\">",
5454            "<img src=\"//cdn.example.com/a.png\">",
5455            "<a href=\"#frag\">x</a>",
5456            "<a href=\"mailto:me@example.com\">x</a>",
5457            "<img src=\"data:image/png;base64,iVBORw0KGgo=\">",
5458            "<img data-src=\"x.png\">",
5459            "<use xlink:href=\"x.svg#a\"/>",
5460            "<img SRC = \"/root.png\">",
5461            "<a HREF=\"#x&amp;y\">x</a>",
5462        ] {
5463            let html = render_with_depth(chunk, None);
5464            assert!(
5465                html.contains(chunk),
5466                "{chunk} must survive verbatim. Got: {html}"
5467            );
5468        }
5469    }
5470
5471    #[test]
5472    fn raw_html_index_page_gets_no_parent_prefix() {
5473        let root = Path::new("/nonexistent-mbr-test-root");
5474        let base = plain_options(root, true);
5475        let opts = RenderOptions {
5476            link_transform_config: LinkTransformConfig {
5477                is_index_file: true,
5478                ..base.link_transform_config.clone()
5479            },
5480            ..base
5481        };
5482        let html = render_str(
5483            "<img src=\"images/x.png\">\n\n<a href=\"other.md\">o</a>",
5484            &root.join("index.md"),
5485            opts,
5486        )
5487        .unwrap()
5488        .html;
5489        assert!(html.contains(r#"<img src="images/x.png">"#), "{html}");
5490        assert!(html.contains(r#"<a href="other/">"#), "{html}");
5491    }
5492
5493    #[test]
5494    fn raw_html_in_code_is_untouched() {
5495        let html = render_with_depth(
5496            "```html\n<img src=\"x.png\">\n```\n\nInline `<img src=\"y.png\">` too.",
5497            None,
5498        );
5499        assert!(html.contains(r#"&lt;img src="x.png"&gt;"#), "{html}");
5500        assert!(html.contains(r#"&lt;img src="y.png"&gt;"#), "{html}");
5501        assert!(
5502            !html.contains("../x.png") && !html.contains("../y.png"),
5503            "{html}"
5504        );
5505    }
5506
5507    #[test]
5508    fn transform_raw_html_urls_borrows_when_nothing_changes() {
5509        let config = plain_options(Path::new("/r"), true).link_transform_config;
5510        for chunk in [
5511            "<div>",
5512            "</p>\n",
5513            "<a href=\"https://x.y/\">",
5514            "<img data-src=\"a.png\">",
5515        ] {
5516            assert!(
5517                matches!(transform_raw_html_urls(chunk, &config), Cow::Borrowed(c) if c == chunk),
5518                "{chunk}"
5519            );
5520        }
5521        // Several attributes in one chunk, only the relative ones rewritten.
5522        let out = transform_raw_html_urls(
5523            "<a href=\"a.md\"><img src=\"https://x/y.png\"> <img src=\"b.png\"></a>",
5524            &config,
5525        );
5526        assert_eq!(
5527            out,
5528            "<a href=\"../a/\"><img src=\"https://x/y.png\"> <img src=\"../b.png\"></a>"
5529        );
5530    }
5531
5532    /// The README's hero: a `<picture>` whose dark-mode `<source srcset>` must
5533    /// resolve like its `<img src>`, or dark mode loads
5534    /// `/README/docs/images/screens/hero-dark.png` and shows nothing.
5535    #[test]
5536    fn raw_html_picture_srcset_resolves_like_its_img() {
5537        let md = concat!(
5538            "<p align=\"center\">\n",
5539            "  <picture>\n",
5540            "    <source media=\"(prefers-color-scheme: dark)\" srcset=\"docs/images/screens/hero-dark.png\">\n",
5541            "    <img src=\"docs/images/screens/hero-light.png\" width=\"900\" alt=\"...\">\n",
5542            "  </picture>\n",
5543            "</p>\n",
5544        );
5545        let root = Path::new("/nonexistent-mbr-test-root");
5546        let html = render_str(md, &root.join("README.md"), plain_options(root, true))
5547            .unwrap()
5548            .html;
5549        assert!(
5550            html.contains(r#"srcset="../docs/images/screens/hero-dark.png""#),
5551            "{html}"
5552        );
5553        assert!(
5554            html.contains(r#"src="../docs/images/screens/hero-light.png""#),
5555            "{html}"
5556        );
5557    }
5558
5559    #[test]
5560    fn raw_html_srcset_candidates_keep_descriptors_in_build_mode() {
5561        let a = image_src("img/a.png", Some(2));
5562        let b = image_src("img/b.png", Some(2));
5563        let html = render_with_depth(
5564            "<img srcset=\"img/a.png 480w,\n     img/b.png 800w\" src=\"img/a.png\">\n",
5565            Some(2),
5566        );
5567        assert!(
5568            html.contains(&format!("srcset=\"{a} 480w,\n     {b} 800w\" src=\"{a}\"")),
5569            "{html}"
5570        );
5571        assert!(a.starts_with("../"), "build depth must add a prefix: {a}");
5572    }
5573
5574    #[test]
5575    fn raw_html_srcset_mixed_external_and_relative() {
5576        let html = render_with_depth(
5577            "<img srcset='https://cdn.example.com/a.png 1x, b.png 2x'>",
5578            None,
5579        );
5580        assert!(
5581            html.contains("srcset='https://cdn.example.com/a.png 1x, ../b.png 2x'"),
5582            "{html}"
5583        );
5584    }
5585
5586    #[test]
5587    fn raw_html_srcset_left_alone_byte_for_byte() {
5588        for chunk in [
5589            "<img srcset=\"https://example.com/a.png 1x, https://example.com/b.png 2x\">",
5590            "<img srcset=\"//cdn.example.com/a.png 2x\">",
5591            "<img srcset=\"/root/a.png 1x,  /root/b.png 2x\">",
5592            "<img data-srcset=\"lazy.png 2x\">",
5593            "<img srcset=\"\">",
5594        ] {
5595            let html = render_with_depth(chunk, None);
5596            assert!(
5597                html.contains(chunk),
5598                "{chunk} must survive verbatim. Got: {html}"
5599            );
5600        }
5601    }
5602
5603    #[test]
5604    fn raw_html_srcset_entities_round_trip() {
5605        let html = render_with_depth("<img srcset=\"a.png?x=1&amp;y=2 2x\">", None);
5606        assert!(
5607            html.contains(r#"srcset="../a.png?x=1&amp;y=2 2x""#),
5608            "{html}"
5609        );
5610    }
5611
5612    /// Pass 1 injects `Event::Html` of its own (task checkbox and text spans,
5613    /// annotation chips, chat bubble wrappers, a bubble's `<hr />`) before
5614    /// pass 2 sees anything. None of it may carry a URL attribute the raw-HTML
5615    /// rewrite would touch.
5616    #[test]
5617    fn pass_one_injected_html_has_no_url_attributes() {
5618        let md = concat!(
5619            "- [ ] open task @due(2026-01-01) #tag !!\n",
5620            "- [x] done @done(2026-01-02 10:30)\n",
5621            "- [-] cancelled\n",
5622            "- [>] deferred\n\n",
5623            "```chat\n<\n[Alice|blue]\n{{Alice|hi there\n\n---\n\nmore|10:00}}\n>\n{{Bob|reply|10:01}}\n```\n",
5624        );
5625        let (events, _, _) = collect_events_and_headings(
5626            md,
5627            TaskMarkup::Render,
5628            &mut TextLines::recording(),
5629            &mut BlockLines::disabled(),
5630        );
5631        let injected: Vec<&str> = events
5632            .iter()
5633            .filter_map(|e| match e {
5634                Event::Html(h) | Event::InlineHtml(h) => Some(h.as_ref()),
5635                _ => None,
5636            })
5637            .collect();
5638        assert!(
5639            injected.iter().any(|h| h.contains("mbr-chat")),
5640            "the fixture must inject chat wrappers: {injected:?}"
5641        );
5642        assert!(
5643            injected.iter().any(|h| h.contains("mbr-task")),
5644            "the fixture must inject task markup: {injected:?}"
5645        );
5646        for html in injected {
5647            assert!(
5648                !HTML_URL_ATTR.is_match(html),
5649                "pass-1 HTML would be rewritten by pass 2: {html}"
5650            );
5651        }
5652    }
5653
5654    // ============ Section background URL tests (Reveal.js slides) ============
5655
5656    /// Renders `content` as a non-index page with the given build-mode depth
5657    /// (`None` = server mode, root-relative URLs left alone).
5658    fn render_with_depth(content: &str, url_depth: Option<usize>) -> String {
5659        let root = Path::new("/nonexistent-mbr-test-root");
5660        let base = plain_options(root, url_depth.is_none());
5661        let opts = RenderOptions {
5662            link_transform_config: LinkTransformConfig {
5663                url_depth,
5664                ..base.link_transform_config.clone()
5665            },
5666            ..base
5667        };
5668        render_str(content, &root.join("deck.md"), opts)
5669            .unwrap()
5670            .html
5671    }
5672
5673    /// The `src` an image with `path` gets on the same page.
5674    fn image_src(path: &str, url_depth: Option<usize>) -> String {
5675        let html = render_with_depth(&format!("![]({path})"), url_depth);
5676        let start = html.find("src=\"").expect("image src") + 5;
5677        let len = html[start..].find('"').expect("closing quote");
5678        html[start..start + len].to_string()
5679    }
5680
5681    fn background_html(attr: &str, value: &str, url_depth: Option<usize>) -> String {
5682        render_with_depth(
5683            &format!("First\n\n--- {{{attr}=\"{value}\"}}\n\nSecond"),
5684            url_depth,
5685        )
5686    }
5687
5688    #[test]
5689    fn section_background_image_is_rewritten_like_an_image_src() {
5690        for depth in [None, Some(0), Some(2)] {
5691            for path in [
5692                "images/x.png",
5693                "./images/x.png",
5694                "../shared/bg.jpg",
5695                "/images/x.png",
5696            ] {
5697                let expected = image_src(path, depth);
5698                let html = background_html("data-background-image", path, depth);
5699                assert!(
5700                    html.contains(&format!(r#"data-background-image="{expected}""#)),
5701                    "depth {depth:?}, {path} should become {expected}. Got: {html}"
5702                );
5703            }
5704        }
5705        // Spelled out once, so the comparison above cannot pass vacuously.
5706        assert!(
5707            background_html("data-background-image", "images/x.png", None)
5708                .contains(r#"data-background-image="../images/x.png""#)
5709        );
5710        assert!(
5711            background_html("data-background-image", "/images/x.png", Some(2))
5712                .contains(r#"data-background-image="../../images/x.png""#)
5713        );
5714    }
5715
5716    #[test]
5717    fn section_background_on_a_nested_page() {
5718        // A page's own folder never enters the arithmetic: `notes/talks/deck.md`
5719        // is served at `/notes/talks/deck/`, one level below its folder, exactly
5720        // like a root-level page — what differs is the build-mode depth.
5721        let html = background_html("data-background-image", "img/bg.png", Some(3));
5722        assert!(
5723            html.contains(r#"data-background-image="../img/bg.png""#),
5724            "{html}"
5725        );
5726        let html = background_html("data-background-image", "/static/bg.png", Some(3));
5727        assert!(
5728            html.contains(r#"data-background-image="../../../static/bg.png""#),
5729            "{html}"
5730        );
5731    }
5732
5733    #[test]
5734    fn section_background_index_page_gets_no_extra_parent() {
5735        let root = Path::new("/nonexistent-mbr-test-root");
5736        let base = plain_options(root, true);
5737        let opts = RenderOptions {
5738            link_transform_config: LinkTransformConfig {
5739                is_index_file: true,
5740                ..base.link_transform_config.clone()
5741            },
5742            ..base
5743        };
5744        let html = render_str(
5745            "A\n\n--- {data-background-image=\"images/x.png\"}\n\nB",
5746            &root.join("index.md"),
5747            opts,
5748        )
5749        .unwrap()
5750        .html;
5751        assert!(
5752            html.contains(r#"data-background-image="images/x.png""#),
5753            "{html}"
5754        );
5755    }
5756
5757    #[test]
5758    fn section_background_external_urls_are_untouched() {
5759        for url in [
5760            "https://example.com/bg.png",
5761            "http://example.com/bg.png",
5762            "//cdn.example.com/bg.png",
5763            "data:image/png;base64,AAAA",
5764        ] {
5765            for attr in [
5766                "data-background-image",
5767                "data-background-video",
5768                "data-background-iframe",
5769                "data-background",
5770            ] {
5771                let html = background_html(attr, url, Some(2));
5772                assert!(
5773                    html.contains(&format!(r#"{attr}="{url}""#)),
5774                    "{attr}={url} should be unchanged. Got: {html}"
5775                );
5776            }
5777        }
5778    }
5779
5780    #[test]
5781    fn section_background_shorthand_rewrites_paths_but_not_colours() {
5782        for colour in [
5783            "#336",
5784            "#ff0000",
5785            "red",
5786            "rgb(10, 20, 30)",
5787            "rgba(0,0,0,0.5)",
5788            "hsl(120, 50%, 50%)",
5789            "linear-gradient(to bottom, #283b95, #17b2c3)",
5790            "transparent",
5791        ] {
5792            let html = background_html("data-background", colour, None);
5793            assert!(
5794                html.contains(&format!(r#"data-background="{colour}""#)),
5795                "colour {colour} must not be rewritten. Got: {html}"
5796            );
5797        }
5798        for (path, expected) in [
5799            ("images/bg.png", "../images/bg.png"),
5800            ("bg.JPG", "../bg.JPG"),
5801            ("img/bg.svg?v=2", "../img/bg.svg?v=2"),
5802            ("img/bg.webp#frag", "../img/bg.webp#frag"),
5803        ] {
5804            let html = background_html("data-background", path, None);
5805            assert!(
5806                html.contains(&format!(r#"data-background="{expected}""#)),
5807                "{path} should become {expected}. Got: {html}"
5808            );
5809        }
5810    }
5811
5812    #[test]
5813    fn section_background_video_list_is_rewritten_per_item() {
5814        let html = background_html(
5815            "data-background-video",
5816            "media/a.mp4, media/a.webm,https://x.example/a.ogv",
5817            None,
5818        );
5819        assert!(
5820            html.contains(
5821                r#"data-background-video="../media/a.mp4, ../media/a.webm,https://x.example/a.ogv""#
5822            ),
5823            "{html}"
5824        );
5825    }
5826
5827    #[test]
5828    fn section_background_iframe_and_other_attrs() {
5829        let html = render_with_depth(
5830            "A\n\n--- {#s .c data-background-iframe=\"embeds/demo.html\" \
5831             data-transition=\"zoom\" data-background-size=\"cover\" \
5832             data-background-color=\"#336\"}\n\nB",
5833            None,
5834        );
5835        assert!(
5836            html.contains(r#"data-background-iframe="../embeds/demo.html""#),
5837            "{html}"
5838        );
5839        for untouched in [
5840            r#"id="s""#,
5841            r#"class="c""#,
5842            r#"data-transition="zoom""#,
5843            r#"data-background-size="cover""#,
5844            r##"data-background-color="#336""##,
5845        ] {
5846            assert!(html.contains(untouched), "{untouched} missing in {html}");
5847        }
5848    }
5849
5850    #[test]
5851    fn reveal_background_url_detection_mirrors_reveal() {
5852        for url in [
5853            "http://x/y",
5854            "https://x/y",
5855            "file:///x",
5856            "//cdn/x",
5857            "a.png",
5858            "a.PNG",
5859            "a.jpeg?x",
5860            "a.gif#y",
5861            "a.bmp b",
5862        ] {
5863            assert!(is_reveal_background_url(url), "{url}");
5864        }
5865        for value in [
5866            "#336",
5867            "red",
5868            "rgb(0,0,0)",
5869            "linear-gradient(red, blue)",
5870            "a.pngx",
5871            "a.png-old",
5872            "",
5873        ] {
5874            assert!(!is_reveal_background_url(value), "{value}");
5875        }
5876    }
5877
5878    // ==================== Incomplete-block marker tests ====================
5879
5880    const DEFAULT_MARKERS: &[&str] = &["TK", "TODO", "FIXME", "XXX"];
5881
5882    // What counts as a marker is tested against `MarkerRule` in `tasks.rs`; the
5883    // tests below cover only what this module adds — which blocks get wrapped,
5884    // which occurrences get wrapped, and which line each anchor claims.
5885
5886    /// A highlight's opening tag up to but not including its optional anchor,
5887    /// so a structural assertion does not have to restate a line number.
5888    const INCOMPLETE_SPAN_PREFIX: &str = "<span class=\"mbr-incomplete\"";
5889
5890    /// Number of highlights in `html`, anchored or not.
5891    fn incomplete_span_count(html: &str) -> usize {
5892        html.matches(INCOMPLETE_SPAN_PREFIX).count()
5893    }
5894
5895    /// The lines claimed by every `#mbr-marker-N` anchor in `html`, in order.
5896    fn marker_anchor_lines(html: &str) -> Vec<u32> {
5897        const ATTR: &str = "id=\"mbr-marker-";
5898        html.match_indices(ATTR)
5899            .map(|(at, _)| {
5900                let rest = &html[at + ATTR.len()..];
5901                let end = rest.find('"').expect("unterminated attribute");
5902                rest[..end].parse().expect("numeric line")
5903            })
5904            .collect()
5905    }
5906
5907    /// `html` with every marker anchor attribute deleted, so the surrounding
5908    /// markup can be asserted exactly. Anchors are checked separately with
5909    /// [`marker_anchor_lines`].
5910    fn without_marker_ids(html: &str) -> String {
5911        const ATTR: &str = " id=\"mbr-marker-";
5912        let mut out = String::with_capacity(html.len());
5913        let mut rest = html;
5914        while let Some(at) = rest.find(ATTR) {
5915            out.push_str(&rest[..at]);
5916            let tail = &rest[at + ATTR.len()..];
5917            let close = tail.find('"').expect("unterminated marker id");
5918            rest = &tail[close + 1..];
5919        }
5920        out.push_str(rest);
5921        out
5922    }
5923
5924    #[tokio::test]
5925    async fn test_incomplete_paragraph() {
5926        let html = render_markdown_marked("TK rewrite this paragraph.", DEFAULT_MARKERS).await;
5927        assert!(
5928            without_marker_ids(&html).contains(r#"<p><span class="mbr-incomplete">"#),
5929            "Paragraph should have span as first child. Got: {html}"
5930        );
5931        assert!(html.contains("TK rewrite"), "TK text preserved: {html}");
5932        assert_eq!(marker_anchor_lines(&html), vec![1], "{html}");
5933    }
5934
5935    #[tokio::test]
5936    async fn test_incomplete_heading() {
5937        let html = render_markdown_marked("## TODO finish this", DEFAULT_MARKERS).await;
5938        assert!(
5939            html.contains(INCOMPLETE_SPAN_PREFIX),
5940            "Span should be present in heading. Got: {html}"
5941        );
5942        // Span must be inside the <h2>, not wrapping it.
5943        assert!(html.contains("<h2"), "h2 element present: {html}");
5944        let h2_start = html.find("<h2").unwrap();
5945        let span_start = html.find(INCOMPLETE_SPAN_PREFIX).unwrap();
5946        assert!(span_start > h2_start, "span should be inside h2: {html}");
5947        assert_eq!(marker_anchor_lines(&html), vec![1], "{html}");
5948    }
5949
5950    #[tokio::test]
5951    async fn test_incomplete_tight_list_item() {
5952        let html = render_markdown_marked("- TK item one\n- normal item", DEFAULT_MARKERS).await;
5953        assert!(
5954            without_marker_ids(&html).contains(r#"<li><span class="mbr-incomplete">"#),
5955            "Span should follow <li> for tight list: {html}"
5956        );
5957        // Only the TK item is wrapped.
5958        assert_eq!(
5959            incomplete_span_count(&html),
5960            1,
5961            "Only one span expected: {html}"
5962        );
5963        assert_eq!(marker_anchor_lines(&html), vec![1], "{html}");
5964    }
5965
5966    #[tokio::test]
5967    async fn test_incomplete_loose_list_item() {
5968        // Blank line between items forces loose list — items wrap their content
5969        // in <p>. The inner <p> is the innermost block, so the span goes there.
5970        let md = "- TK draft this\n\n- finished item\n";
5971        let html = render_markdown_marked(md, DEFAULT_MARKERS).await;
5972        let bare = without_marker_ids(&html);
5973        assert!(
5974            bare.contains(r#"<p><span class="mbr-incomplete">"#),
5975            "Span should wrap inner <p> in loose list: {html}"
5976        );
5977        // The <li> itself should not have the span as a direct child.
5978        assert!(
5979            !bare.contains(r#"<li><span class="mbr-incomplete">"#),
5980            "Loose-list <li> should not have direct span child: {html}"
5981        );
5982        assert_eq!(marker_anchor_lines(&html), vec![1], "{html}");
5983    }
5984
5985    #[tokio::test]
5986    async fn test_incomplete_table_cell() {
5987        let md = "| H |\n|---|\n| TK cell |\n";
5988        let html = render_markdown_marked(md, DEFAULT_MARKERS).await;
5989        assert!(
5990            without_marker_ids(&html).contains(r#"<td><span class="mbr-incomplete">"#),
5991            "Span should follow <td> for incomplete cell: {html}"
5992        );
5993        assert_eq!(marker_anchor_lines(&html), vec![3], "{html}");
5994    }
5995
5996    #[tokio::test]
5997    async fn test_remark_hint_tip() {
5998        let html = render_markdown("!> tip").await;
5999        assert!(
6000            html.contains(r#"<blockquote class="markdown-alert-tip">"#),
6001            "Expected tip alert blockquote: {html}"
6002        );
6003        assert!(
6004            html.contains("<p>tip</p>"),
6005            "Marker should be stripped: {html}"
6006        );
6007        assert!(!html.contains("!&gt;"), "Escaped marker leaked: {html}");
6008        assert!(!html.contains("!>"), "Raw marker leaked: {html}");
6009    }
6010
6011    #[tokio::test]
6012    async fn test_remark_hint_warning() {
6013        let html = render_markdown("?> warn").await;
6014        assert!(
6015            html.contains(r#"<blockquote class="markdown-alert-warning">"#),
6016            "Expected warning alert blockquote: {html}"
6017        );
6018        assert!(
6019            html.contains("<p>warn</p>"),
6020            "Marker should be stripped: {html}"
6021        );
6022    }
6023
6024    #[tokio::test]
6025    async fn test_remark_hint_caution() {
6026        let html = render_markdown("x> caution").await;
6027        assert!(
6028            html.contains(r#"<blockquote class="markdown-alert-caution">"#),
6029            "Expected caution alert blockquote: {html}"
6030        );
6031        assert!(
6032            html.contains("<p>caution</p>"),
6033            "Marker should be stripped: {html}"
6034        );
6035    }
6036
6037    #[tokio::test]
6038    async fn test_remark_hint_multiline() {
6039        // A soft-wrapped paragraph: marker stripped from the first line, the rest
6040        // of the paragraph stays inside the same alert.
6041        let html = render_markdown("!> line one\nline two").await;
6042        assert!(
6043            html.contains(r#"<blockquote class="markdown-alert-tip">"#),
6044            "Expected tip alert blockquote: {html}"
6045        );
6046        assert!(html.contains("line one"), "First line retained: {html}");
6047        assert!(html.contains("line two"), "Second line retained: {html}");
6048        assert!(!html.contains("!&gt;"), "Escaped marker leaked: {html}");
6049        assert!(!html.contains("!>"), "Raw marker leaked: {html}");
6050    }
6051
6052    #[tokio::test]
6053    async fn test_remark_hint_requires_trailing_space() {
6054        // No space after the marker -> not a hint.
6055        let html = render_markdown("!>no-space").await;
6056        assert!(
6057            !html.contains("markdown-alert"),
6058            "Should not be converted without trailing space: {html}"
6059        );
6060    }
6061
6062    #[tokio::test]
6063    async fn test_remark_hint_only_at_paragraph_start() {
6064        // Mid-paragraph occurrence is not a hint.
6065        let html = render_markdown("text !> more").await;
6066        assert!(
6067            !html.contains("markdown-alert"),
6068            "Mid-paragraph marker should not be converted: {html}"
6069        );
6070    }
6071
6072    #[tokio::test]
6073    async fn test_remark_hint_ignored_in_code_block() {
6074        // A fenced code block containing a hint-like line must render verbatim.
6075        let html = render_markdown("```\n!> foo\n```").await;
6076        assert!(
6077            !html.contains("markdown-alert"),
6078            "Code block content should not be converted: {html}"
6079        );
6080        assert!(
6081            html.contains("!&gt; foo") || html.contains("!> foo"),
6082            "Code content should render verbatim: {html}"
6083        );
6084    }
6085
6086    #[tokio::test]
6087    async fn test_native_github_alert_still_works() {
6088        // Regression: native pulldown-cmark GitHub alerts continue to render.
6089        let html = render_markdown("> [!TIP]\n> hello").await;
6090        assert!(
6091            html.contains(r#"<blockquote class="markdown-alert-tip">"#),
6092            "Native GitHub alert should still render: {html}"
6093        );
6094        assert!(html.contains("hello"), "Alert content retained: {html}");
6095    }
6096
6097    #[tokio::test]
6098    async fn test_incomplete_blockquote_paragraph() {
6099        // Blockquote itself is not eligible; its inner Paragraph is.
6100        let html = render_markdown_marked("> TK quote me", DEFAULT_MARKERS).await;
6101        assert!(
6102            without_marker_ids(&html).contains(r#"<p><span class="mbr-incomplete">"#),
6103            "Inner <p> should carry the span, not <blockquote>: {html}"
6104        );
6105        assert!(
6106            !html.contains(r#"<blockquote><span"#),
6107            "Blockquote should not be span-wrapped: {html}"
6108        );
6109    }
6110
6111    #[tokio::test]
6112    async fn test_incomplete_with_strong_emphasis() {
6113        // Span goes immediately after <p>, so it wraps the <strong>.
6114        let html = render_markdown_marked("**TK** finish later", DEFAULT_MARKERS).await;
6115        assert!(
6116            without_marker_ids(&html)
6117                .contains(r#"<p><span class="mbr-incomplete"><strong>TK</strong>"#),
6118            "Span should wrap <strong>TK</strong>: {html}"
6119        );
6120        assert_eq!(marker_anchor_lines(&html), vec![1], "{html}");
6121    }
6122
6123    /// The block-initial wrap already covers the marker that opened it, so the
6124    /// per-occurrence pass must skip that occurrence. Without the skip range
6125    /// `**TK** and later TK` would emit a third span nested inside the first.
6126    #[tokio::test]
6127    async fn block_initial_marker_inside_strong_is_not_wrapped_twice() {
6128        let html = render_markdown_marked("**TK** and later TK", DEFAULT_MARKERS).await;
6129        assert_eq!(
6130            incomplete_span_count(&html),
6131            2,
6132            "block wrap plus one inline wrap, not three: {html}"
6133        );
6134        assert!(
6135            without_marker_ids(&html)
6136                .contains(r#"<p><span class="mbr-incomplete"><strong>TK</strong>"#),
6137            "the block wrap must still start at <strong>: {html}"
6138        );
6139    }
6140
6141    #[tokio::test]
6142    async fn test_incomplete_with_link() {
6143        // A link starting the paragraph: span should wrap the <a>.
6144        let html =
6145            render_markdown_marked("[TK](https://example.com) check this", DEFAULT_MARKERS).await;
6146        assert!(
6147            without_marker_ids(&html).contains(r#"<p><span class="mbr-incomplete"><a "#),
6148            "Span should wrap the link: {html}"
6149        );
6150    }
6151
6152    #[tokio::test]
6153    async fn test_incomplete_negative_tomato() {
6154        // Word starting with "T" but not a marker.
6155        let html = render_markdown_marked("Tomato is red.", DEFAULT_MARKERS).await;
6156        assert!(
6157            !html.contains("mbr-incomplete"),
6158            "'Tomato' should not match: {html}"
6159        );
6160    }
6161
6162    #[tokio::test]
6163    async fn test_incomplete_negative_lowercase() {
6164        let html = render_markdown_marked("Tk lowercase ignored.", DEFAULT_MARKERS).await;
6165        assert!(
6166            !html.contains("mbr-incomplete"),
6167            "Mixed case 'Tk' should not match: {html}"
6168        );
6169        let html2 = render_markdown_marked("todo lowercase.", DEFAULT_MARKERS).await;
6170        assert!(
6171            !html2.contains("mbr-incomplete"),
6172            "lowercase 'todo' should not match: {html2}"
6173        );
6174    }
6175
6176    #[tokio::test]
6177    async fn test_incomplete_negative_word_boundary() {
6178        // TKTK and TODOs must not match (no word boundary at marker end).
6179        let html = render_markdown_marked("TKTK shouldn't match.", DEFAULT_MARKERS).await;
6180        assert!(
6181            !html.contains("mbr-incomplete"),
6182            "TKTK should not match: {html}"
6183        );
6184        let html2 = render_markdown_marked("TODOs are plural.", DEFAULT_MARKERS).await;
6185        assert!(
6186            !html2.contains("mbr-incomplete"),
6187            "'TODOs' should not match: {html2}"
6188        );
6189    }
6190
6191    /// Inverted deliberately: a marker anywhere in a line is now highlighted.
6192    /// Only the marker *word* is wrapped, though — the block-wide wash stays
6193    /// reserved for a block that opens with one.
6194    #[tokio::test]
6195    async fn incomplete_mid_paragraph_highlights_the_marker_word() {
6196        let html =
6197            render_markdown_marked("This paragraph mentions TK in the middle.", DEFAULT_MARKERS)
6198                .await;
6199        assert!(
6200            without_marker_ids(&html).contains(
6201                r#"This paragraph mentions <span class="mbr-incomplete">TK</span> in the middle."#
6202            ),
6203            "only the marker word should be wrapped: {html}"
6204        );
6205        assert!(
6206            !without_marker_ids(&html).contains(r#"<p><span class="mbr-incomplete">"#),
6207            "the block itself must not be wrapped: {html}"
6208        );
6209        assert_eq!(marker_anchor_lines(&html), vec![1], "{html}");
6210    }
6211
6212    #[tokio::test]
6213    async fn test_incomplete_negative_code_block() {
6214        // Code blocks never push a frame, so the TK inside is ignored.
6215        let md = "```\nTK code lines\n```\n";
6216        let html = render_markdown_marked(md, DEFAULT_MARKERS).await;
6217        assert!(
6218            !html.contains("mbr-incomplete"),
6219            "TK in code block should not match: {html}"
6220        );
6221    }
6222
6223    /// A code block *inside a list item* keeps the `Item` frame open with no
6224    /// text seen yet, so before the `code_depth` guard the block-initial test
6225    /// ran on the code's own text and wrapped the whole `<li>`. Both spellings
6226    /// of "code inside a list item" are covered: the fence and the five-space
6227    /// indent that CommonMark also reads as code.
6228    #[tokio::test]
6229    async fn marker_in_a_code_block_inside_a_list_item_is_not_highlighted() {
6230        let fenced = "- ```\n  TK not a task\n  ```\n";
6231        let html = render_markdown_marked(fenced, DEFAULT_MARKERS).await;
6232        assert!(
6233            !html.contains("mbr-incomplete"),
6234            "fenced code in a list item must not be highlighted: {html}"
6235        );
6236
6237        let indented = "-     TK not a task\n";
6238        let html = render_markdown_marked(indented, DEFAULT_MARKERS).await;
6239        assert!(
6240            !html.contains("mbr-incomplete"),
6241            "indented code in a list item must not be highlighted: {html}"
6242        );
6243    }
6244
6245    #[tokio::test]
6246    async fn test_incomplete_negative_frontmatter() {
6247        let md = "---\ntitle: TK rename later\n---\n\nNormal paragraph.";
6248        let html = render_markdown_marked(md, DEFAULT_MARKERS).await;
6249        assert!(
6250            !html.contains("mbr-incomplete"),
6251            "TK in frontmatter should not match: {html}"
6252        );
6253    }
6254
6255    #[tokio::test]
6256    async fn test_incomplete_disabled_no_span() {
6257        // mark_incomplete=false → never injects spans, even with markers present.
6258        let html = render_markdown("TK should not be highlighted.").await;
6259        assert!(
6260            !html.contains("mbr-incomplete"),
6261            "Disabled flag suppresses span: {html}"
6262        );
6263    }
6264
6265    #[tokio::test]
6266    async fn test_incomplete_custom_markers() {
6267        let html = render_markdown_marked("NOTE this draft.", &["NOTE"]).await;
6268        assert!(
6269            without_marker_ids(&html).contains(r#"<p><span class="mbr-incomplete">"#),
6270            "Custom marker NOTE should match: {html}"
6271        );
6272        // TK is not in the custom marker list now.
6273        let html2 = render_markdown_marked("TK ignored under custom list.", &["NOTE"]).await;
6274        assert!(
6275            !html2.contains("mbr-incomplete"),
6276            "TK should not match when only NOTE configured: {html2}"
6277        );
6278    }
6279
6280    #[tokio::test]
6281    async fn test_incomplete_empty_markers_no_op() {
6282        // Empty marker list short-circuits the pass: no spans injected.
6283        let html = render_markdown_marked("TK still here.", &[]).await;
6284        assert!(
6285            !html.contains("mbr-incomplete"),
6286            "Empty marker list should not inject spans: {html}"
6287        );
6288    }
6289
6290    // ---- markers anywhere in a line -----------------------------------------
6291
6292    /// The case the whole feature exists for: a marker buried in prose used to
6293    /// be invisible both on the page and to the task browser.
6294    #[tokio::test]
6295    async fn marker_embedded_in_prose_is_highlighted() {
6296        let html =
6297            render_markdown_marked("The market fell 10% (source: TK).", DEFAULT_MARKERS).await;
6298        assert_eq!(incomplete_span_count(&html), 1, "{html}");
6299        assert_eq!(marker_anchor_lines(&html), vec![1], "{html}");
6300    }
6301
6302    #[tokio::test]
6303    async fn several_markers_in_one_run_each_get_a_span() {
6304        let html =
6305            render_markdown_marked("Fix TODO then FIXME then XXX later.", DEFAULT_MARKERS).await;
6306        assert_eq!(
6307            incomplete_span_count(&html),
6308            3,
6309            "one span per occurrence: {html}"
6310        );
6311    }
6312
6313    /// The split must not drop, duplicate or re-escape the text around a marker.
6314    #[tokio::test]
6315    async fn text_before_and_after_a_marker_survives_intact() {
6316        let html =
6317            render_markdown_marked("The market fell 10% (source: TK).", DEFAULT_MARKERS).await;
6318        assert!(
6319            html.contains(concat!(
6320                r#"<p>The market fell 10% (source: "#,
6321                r#"<span class="mbr-incomplete" id="mbr-marker-1">TK</span>).</p>"#
6322            )),
6323            "{html}"
6324        );
6325    }
6326
6327    /// Smart punctuation (`Options::all()`) rewrites `--` and `"` before pass 3
6328    /// sees the text, so text byte offsets no longer match source byte offsets.
6329    /// Anchors survive because they come from the run's *range*, never from an
6330    /// offset into the run.
6331    #[tokio::test]
6332    async fn smart_punctuation_does_not_shift_the_anchor_line() {
6333        let md = concat!(
6334            "An em -- dash and \"quotes\" on line one.\n",
6335            "\n",
6336            "More -- dashes and \"quotes\" here.\n",
6337            "\n",
6338            "Now a TK appears -- after more punctuation.\n",
6339        );
6340        let html = render_markdown_marked(md, DEFAULT_MARKERS).await;
6341        assert!(
6342            html.contains('\u{2013}'),
6343            "smart punctuation must actually have run: {html}"
6344        );
6345        assert_eq!(marker_anchor_lines(&html), vec![5], "{html}");
6346    }
6347
6348    /// A `SoftBreak` terminates a text merge, so the second line is its own run
6349    /// and reports its own line.
6350    #[tokio::test]
6351    async fn marker_in_a_soft_wrapped_paragraph_gets_its_own_line() {
6352        let html = render_markdown_marked(
6353            "First line of prose.\nSecond line has TK here.",
6354            DEFAULT_MARKERS,
6355        )
6356        .await;
6357        assert_eq!(marker_anchor_lines(&html), vec![2], "{html}");
6358        assert!(
6359            !without_marker_ids(&html).contains(r#"<p><span class="mbr-incomplete">"#),
6360            "a mid-paragraph marker must not wash the block: {html}"
6361        );
6362    }
6363
6364    #[tokio::test]
6365    async fn marker_after_a_hard_break_gets_its_own_line() {
6366        let html = render_markdown_marked("First line.\\\nTK second.", DEFAULT_MARKERS).await;
6367        assert_eq!(marker_anchor_lines(&html), vec![2], "{html}");
6368        assert!(html.contains("<br />"), "hard break expected: {html}");
6369    }
6370
6371    /// Duplicate ids are invalid HTML and leave `getElementById` free to pick
6372    /// either one, so only the first highlight on a line is anchored.
6373    #[tokio::test]
6374    async fn two_markers_on_one_line_share_no_id() {
6375        let html = render_markdown_marked("See TK here and TK there.", DEFAULT_MARKERS).await;
6376        assert_eq!(incomplete_span_count(&html), 2, "{html}");
6377        assert_eq!(marker_anchor_lines(&html), vec![1], "{html}");
6378    }
6379
6380    /// Table cells share a source line by construction, which is what made a
6381    /// per-line anchor budget necessary in the first place.
6382    #[tokio::test]
6383    async fn two_table_cells_on_one_line_share_no_id() {
6384        let md = "| A | B |\n|---|---|\n| TK a | TK b |\n";
6385        let html = render_markdown_marked(md, DEFAULT_MARKERS).await;
6386        assert_eq!(incomplete_span_count(&html), 2, "{html}");
6387        assert_eq!(marker_anchor_lines(&html), vec![3], "{html}");
6388    }
6389
6390    // ---- what stays unhighlighted -------------------------------------------
6391
6392    /// An inline code span is `Event::Code`, never `Event::Text`, so it never
6393    /// reaches the wrap.
6394    #[tokio::test]
6395    async fn marker_in_inline_code_is_not_highlighted() {
6396        let html = render_markdown_marked("Some `TODO` here.", DEFAULT_MARKERS).await;
6397        assert!(!html.contains("mbr-incomplete"), "{html}");
6398        assert!(html.contains("<code>TODO</code>"), "{html}");
6399    }
6400
6401    /// Destinations and titles are attributes on the `Start` tag, not text.
6402    #[tokio::test]
6403    async fn marker_in_a_link_destination_is_not_highlighted() {
6404        let html = render_markdown_marked(
6405            r#"See [the notes](https://example.com/TODO-list "TODO later") for context."#,
6406            DEFAULT_MARKERS,
6407        )
6408        .await;
6409        assert!(!html.contains("mbr-incomplete"), "{html}");
6410        assert!(html.contains("TODO-list"), "destination preserved: {html}");
6411    }
6412
6413    /// `html::raw_text` drops injected `Event::Html`, so a span inside alt text
6414    /// would vanish *and* consume the line's one anchor. The marker after the
6415    /// image must therefore still be the one that gets it.
6416    #[tokio::test]
6417    async fn marker_in_image_alt_text_is_not_highlighted() {
6418        let html = render_markdown_marked("![TK](x.png) and TK", DEFAULT_MARKERS).await;
6419        assert!(html.contains(r#"alt="TK""#), "alt text intact: {html}");
6420        assert_eq!(
6421            incomplete_span_count(&html),
6422            1,
6423            "only the marker outside the image is wrapped: {html}"
6424        );
6425        assert_eq!(marker_anchor_lines(&html), vec![1], "{html}");
6426    }
6427
6428    // ---- anchors survive every pass-1 rewrite -------------------------------
6429
6430    /// The `--- {attrs}` rewrite pops a *recorded* `Text` and reuses its slot
6431    /// for a `Rule`. If the text-line table were not truncated with the vector,
6432    /// a stale record could claim the slot a later run lands in and anchor the
6433    /// marker to the rule's line instead of its own.
6434    #[tokio::test]
6435    async fn rule_attrs_paragraph_does_not_orphan_a_text_line_record() {
6436        let md = "Intro prose.\n\n--- {#intro}\n\nTK draft this section.\n";
6437        let html = render_markdown_marked(md, DEFAULT_MARKERS).await;
6438        assert!(
6439            html.contains(r#"id="intro""#),
6440            "the rule attrs must still be applied: {html}"
6441        );
6442        assert_eq!(marker_anchor_lines(&html), vec![5], "{html}");
6443    }
6444
6445    /// The hint rewrite pushes a *new* `Text` for the remainder of the line;
6446    /// it has to carry the line the original run came from.
6447    #[tokio::test]
6448    async fn remark_hint_paragraph_carries_its_marker_line() {
6449        let html = render_markdown_marked("Intro.\n\n!> TK check this\n", DEFAULT_MARKERS).await;
6450        assert!(html.contains("markdown-alert-tip"), "{html}");
6451        assert_eq!(marker_anchor_lines(&html), vec![3], "{html}");
6452    }
6453
6454    /// `[-]` and `[>]` arrive glued to the display text, so pass 1 puts the
6455    /// remainder back as a fresh `Text` — which must keep the line too.
6456    #[tokio::test]
6457    async fn extended_task_marker_carries_its_marker_line() {
6458        let html = render_markdown_marked("Intro.\n\n- [-] TK abandoned\n", DEFAULT_MARKERS).await;
6459        assert!(html.contains(r#"data-mbr-task-line="3""#), "{html}");
6460        assert_eq!(marker_anchor_lines(&html), vec![3], "{html}");
6461    }
6462
6463    #[tokio::test]
6464    async fn crlf_line_endings_do_not_shift_marker_anchors() {
6465        let html =
6466            render_markdown_marked("TK one\r\n\r\nprose\r\n\r\nTK two\r\n", DEFAULT_MARKERS).await;
6467        assert_eq!(marker_anchor_lines(&html), vec![1, 5], "{html}");
6468    }
6469
6470    #[tokio::test]
6471    async fn frontmatter_does_not_shift_marker_anchors() {
6472        let md = "---\ntitle: TK rename later\n---\n\nTK here.\n";
6473        let html = render_markdown_marked(md, DEFAULT_MARKERS).await;
6474        assert_eq!(
6475            incomplete_span_count(&html),
6476            1,
6477            "the frontmatter marker is not highlighted: {html}"
6478        );
6479        assert_eq!(marker_anchor_lines(&html), vec![5], "{html}");
6480    }
6481
6482    /// Heading labels are harvested in pass 1, long before pass 3 injects any
6483    /// markup, so the table of contents must stay plain text.
6484    #[tokio::test]
6485    async fn heading_toc_text_has_no_marker_markup() {
6486        let result = render_result_marked("# TODO write the intro\n", DEFAULT_MARKERS).await;
6487        assert_eq!(result.headings[0].text, "TODO write the intro");
6488        assert!(
6489            result.html.contains(INCOMPLETE_SPAN_PREFIX),
6490            "the heading itself is still highlighted: {}",
6491            result.html
6492        );
6493    }
6494
6495    /// The async and rayon entry points duplicate the whole pipeline, so they
6496    /// have to be shown to agree rather than assumed to.
6497    #[tokio::test]
6498    async fn render_sync_and_render_agree_on_marker_anchors() {
6499        let md = concat!(
6500            "---\ntitle: T\n---\n\n",
6501            "# TODO heading\n\n",
6502            "Prose with a TK in the middle and another TK after it.\n",
6503            "A soft-wrapped TK line.\n\n",
6504            "- [ ] TODO: a task\n",
6505            "- plain item\n\n",
6506            "```\nTK in code\n```\n\n",
6507            "| A | B |\n|---|---|\n| TK a | TK b |\n",
6508        );
6509        let mut file = NamedTempFile::new().unwrap();
6510        file.write_all(md.as_bytes()).unwrap();
6511        let path = file.path().to_path_buf();
6512        let root = path.parent().unwrap().to_path_buf();
6513        let config = LinkTransformConfig {
6514            markdown_extensions: vec!["md".to_string()],
6515            index_file: "index.md".to_string(),
6516            is_index_file: false,
6517            url_depth: None,
6518            current_page_url: String::new(),
6519            markdown_page_probe: None,
6520        };
6521        let markers: Vec<String> = DEFAULT_MARKERS.iter().map(|m| m.to_string()).collect();
6522
6523        let async_html = render(
6524            path.clone(),
6525            &root,
6526            0,
6527            config.clone(),
6528            false,
6529            false,
6530            HashSet::new(),
6531            ReviewLines::Omit,
6532            true,
6533            &markers,
6534            None,
6535        )
6536        .await
6537        .unwrap()
6538        .html;
6539        let sync_html = render_sync(
6540            path,
6541            &root,
6542            0,
6543            config,
6544            None,
6545            false,
6546            false,
6547            HashSet::new(),
6548            ReviewLines::Omit,
6549            true,
6550            &markers,
6551            None,
6552        )
6553        .unwrap()
6554        .html;
6555
6556        assert_eq!(async_html, sync_html);
6557        assert!(!marker_anchor_lines(&async_html).is_empty(), "{async_html}");
6558    }
6559
6560    /// `TextLines` addresses pass-3 events by their pass-1 index, which is only
6561    /// sound because pass 2 neither inserts nor drops.
6562    #[test]
6563    fn process_all_events_maps_one_event_to_one_event() {
6564        let md = concat!(
6565            "---\ntitle: T\n---\n\n",
6566            "# Heading\n\n",
6567            "Prose with a [link](other.md) and an image ![alt](pic.png).\n\n",
6568            "https://youtu.be/dQw4w9WgXcQ\n\n",
6569            "{{ vid(path=\"clip.mp4\") }}\n\n",
6570            "- [ ] a task @due(2026-01-01)\n",
6571            "- item with `code`\n\n",
6572            "```rust\nfn main() {}\n```\n\n",
6573            "| a | b |\n|---|---|\n| c | d |\n\n",
6574            "> quote\n\n",
6575            "--- {#id .cls}\n\n",
6576            "!> hint\n\n",
6577            "<p align=\"center\">\n<img src=\"docs/x.png\">\n</p>\n\n",
6578            "Inline <a href=\"other.md\">raw</a> link.\n",
6579        );
6580        let mut text_lines = TextLines::recording();
6581        let (events, _, _) = collect_events_and_headings(
6582            md,
6583            TaskMarkup::Render,
6584            &mut text_lines,
6585            &mut BlockLines::disabled(),
6586        );
6587        let expected = events.len();
6588        let config = LinkTransformConfig {
6589            markdown_extensions: vec!["md".to_string()],
6590            index_file: "index.md".to_string(),
6591            is_index_file: false,
6592            url_depth: None,
6593            current_page_url: String::new(),
6594            markdown_page_probe: None,
6595        };
6596        let (processed, _state) = process_all_events(
6597            events,
6598            Path::new("/tmp"),
6599            Path::new("/tmp/note.md"),
6600            config,
6601            HashMap::new(),
6602            false,
6603            false,
6604            HashSet::new(),
6605            None,
6606        );
6607        assert_eq!(processed.len(), expected);
6608    }
6609
6610    // Wikilink and tag link tests
6611
6612    fn make_sources(sources: &[&str]) -> HashSet<String> {
6613        sources.iter().map(|s| s.to_string()).collect()
6614    }
6615
6616    #[tokio::test]
6617    async fn test_wikilink_transformation() {
6618        // [[Tags:rust]] should become a link to /tags/rust/
6619        let sources = make_sources(&["tags"]);
6620        let md = "Check out [[Tags:rust]] for more info.";
6621        let html = render_markdown_with_tags(md, sources).await;
6622        assert!(
6623            html.contains(r#"href="/tags/rust/""#),
6624            "Wikilink should transform to tag URL. Got: {}",
6625            html
6626        );
6627        assert!(
6628            html.contains(">rust<"),
6629            "Link text should be the tag value. Got: {}",
6630            html
6631        );
6632    }
6633
6634    #[tokio::test]
6635    async fn test_wikilink_with_spaces() {
6636        // [[performers:Joshua Jay]] should become a link to /performers/joshua_jay/
6637        let sources = make_sources(&["performers"]);
6638        let md = "Watch [[performers:Joshua Jay]] perform!";
6639        let html = render_markdown_with_tags(md, sources).await;
6640        assert!(
6641            html.contains(r#"href="/performers/joshua_jay/""#),
6642            "Wikilink with spaces should normalize URL. Got: {}",
6643            html
6644        );
6645        assert!(
6646            html.contains(">Joshua Jay<"),
6647            "Link text should preserve original case. Got: {}",
6648            html
6649        );
6650    }
6651
6652    #[tokio::test]
6653    async fn test_wikilink_unknown_source_becomes_native_wikilink() {
6654        // [[category:books]] - category is not a valid tag source, so transform_wikilinks
6655        // leaves it alone. But pulldown-cmark's native wikilink support picks it up
6656        // and renders it as a link to "category:books".
6657        let sources = make_sources(&["tags"]);
6658        let md = "See [[category:books]] for more.";
6659        let html = render_markdown_with_tags(md, sources).await;
6660        // With native wikilink support, this becomes a link (not literal text)
6661        assert!(
6662            html.contains("<a"),
6663            "Wikilink should become a link via pulldown-cmark. Got: {}",
6664            html
6665        );
6666        // The link destination should be the wikilink content
6667        assert!(
6668            html.contains("category:books"),
6669            "Link should reference the wikilink content. Got: {}",
6670            html
6671        );
6672    }
6673
6674    #[tokio::test]
6675    async fn test_markdown_tag_link() {
6676        // [text](Tags:rust) should become a link to /tags/rust/
6677        let sources = make_sources(&["tags"]);
6678        let md = "[Learn Rust](Tags:rust)";
6679        let html = render_markdown_with_tags(md, sources).await;
6680        assert!(
6681            html.contains(r#"href="/tags/rust/""#),
6682            "Tag link should transform to tag URL. Got: {}",
6683            html
6684        );
6685        assert!(
6686            html.contains(">Learn Rust<"),
6687            "Link text should be preserved. Got: {}",
6688            html
6689        );
6690    }
6691
6692    #[tokio::test]
6693    async fn test_markdown_tag_link_normalized() {
6694        // [Great performer](performers:joshua_jay) -> /performers/joshua_jay/
6695        // Note: Markdown link destinations can't contain unescaped spaces,
6696        // so tag values in [text](Source:value) format must be pre-normalized.
6697        // Use [[Source:value with spaces]] wikilink format for values with spaces.
6698        let sources = make_sources(&["performers"]);
6699        let md = "[Great performer](performers:joshua_jay)";
6700        let html = render_markdown_with_tags(md, sources).await;
6701        assert!(
6702            html.contains(r#"href="/performers/joshua_jay/""#),
6703            "Tag link should transform to tag URL. Got: {}",
6704            html
6705        );
6706    }
6707
6708    #[tokio::test]
6709    async fn test_url_scheme_not_treated_as_tag() {
6710        // [Example](https://example.com) should remain a regular URL
6711        let sources = make_sources(&["tags", "https"]); // Even if https is a source
6712        let md = "[Example](https://example.com)";
6713        let html = render_markdown_with_tags(md, sources).await;
6714        assert!(
6715            html.contains(r#"href="https://example.com""#),
6716            "URL schemes should not be treated as tag sources. Got: {}",
6717            html
6718        );
6719    }
6720
6721    #[tokio::test]
6722    async fn test_multiple_wikilinks() {
6723        // Multiple wikilinks in one document
6724        let sources = make_sources(&["tags"]);
6725        let md = "Learn [[Tags:rust]] and [[Tags:python]] today!";
6726        let html = render_markdown_with_tags(md, sources).await;
6727        assert!(
6728            html.contains(r#"href="/tags/rust/""#),
6729            "First wikilink should work. Got: {}",
6730            html
6731        );
6732        assert!(
6733            html.contains(r#"href="/tags/python/""#),
6734            "Second wikilink should work. Got: {}",
6735            html
6736        );
6737    }
6738
6739    #[tokio::test]
6740    async fn test_nested_tag_source() {
6741        // [[taxonomy.tags:rust]] for nested frontmatter fields
6742        let sources = make_sources(&["taxonomy.tags"]);
6743        let md = "See [[taxonomy.tags:rust]] for more.";
6744        let html = render_markdown_with_tags(md, sources).await;
6745        assert!(
6746            html.contains(r#"href="/taxonomy.tags/rust/""#),
6747            "Nested tag source should work. Got: {}",
6748            html
6749        );
6750    }
6751
6752    #[tokio::test]
6753    async fn test_no_tag_sources_uses_native_wikilinks() {
6754        // When no tag sources configured, transform_wikilinks is skipped entirely.
6755        // pulldown-cmark's native wikilink support still applies, rendering
6756        // [[Tags:rust]] as a link to "Tags:rust".
6757        let sources = HashSet::new();
6758        let md = "See [[Tags:rust]] for more.";
6759        let html = render_markdown_with_tags(md, sources).await;
6760        // With native wikilink support, this becomes a link (not literal text)
6761        assert!(
6762            html.contains("<a"),
6763            "Wikilink should become a link via pulldown-cmark. Got: {}",
6764            html
6765        );
6766        assert!(
6767            html.contains("Tags:rust"),
6768            "Link should reference the wikilink content. Got: {}",
6769            html
6770        );
6771    }
6772
6773    // Regression tests for plain wikilinks (no colon/source prefix)
6774    // These verify that pulldown-cmark's native wikilink support works correctly
6775
6776    #[tokio::test]
6777    async fn test_plain_wikilink_works() {
6778        // Plain [[MyPage]] should become a link to MyPage
6779        let html = render_markdown("Check out [[MyPage]] for more.").await;
6780        assert!(
6781            html.contains("<a"),
6782            "Plain wikilink should become a link. Got: {}",
6783            html
6784        );
6785        assert!(
6786            html.contains("MyPage"),
6787            "Link should reference MyPage. Got: {}",
6788            html
6789        );
6790    }
6791
6792    #[tokio::test]
6793    async fn test_plain_wikilink_with_spaces() {
6794        // [[My Page]] should work with spaces
6795        let html = render_markdown("See [[My Page]] here.").await;
6796        assert!(
6797            html.contains("<a"),
6798            "Wikilink with spaces should become a link. Got: {}",
6799            html
6800        );
6801        assert!(
6802            html.contains("My Page"),
6803            "Link should preserve the page name. Got: {}",
6804            html
6805        );
6806    }
6807
6808    #[tokio::test]
6809    async fn test_tag_and_plain_wikilinks_together() {
6810        // Both tag-style and plain wikilinks should work in the same document
6811        let sources = make_sources(&["tags"]);
6812        let md = "See [[Tags:rust]] and also [[MyPage]] for info.";
6813        let html = render_markdown_with_tags(md, sources).await;
6814        // Tag wikilink should go to /tags/rust/
6815        assert!(
6816            html.contains(r#"href="/tags/rust/""#),
6817            "Tag wikilink should transform to /tags/rust/. Got: {}",
6818            html
6819        );
6820        // Plain wikilink should become a link to MyPage
6821        assert!(
6822            html.contains("MyPage"),
6823            "Plain wikilink should reference MyPage. Got: {}",
6824            html
6825        );
6826        // Should have two links
6827        let link_count = html.matches("<a").count();
6828        assert!(
6829            link_count >= 2,
6830            "Should have at least 2 links. Got {} in: {}",
6831            link_count,
6832            html
6833        );
6834    }
6835
6836    #[tokio::test]
6837    async fn test_code_blocks_with_unsupported_language() {
6838        // Code blocks with unknown languages must still render valid HTML
6839        // so that hljs can gracefully skip them at runtime
6840        let md = "```unknownlang\nsome code\n```";
6841        let html = render_markdown(md).await;
6842        assert!(
6843            html.contains("<pre><code class=\"language-unknownlang\">"),
6844            "Unsupported language should still get a language class. Got: {}",
6845            html
6846        );
6847        assert!(html.contains("some code"));
6848    }
6849
6850    #[tokio::test]
6851    async fn test_code_blocks_mixed_supported_and_unsupported_languages() {
6852        // When valid and invalid languages coexist, all blocks must render
6853        // with proper language classes so hljs can highlight what it can
6854        let md = concat!(
6855            "```rust\nfn main() {}\n```\n\n",
6856            "```garbage_lang_404\nfoo bar\n```\n\n",
6857            "```python\nprint(1)\n```",
6858        );
6859        let html = render_markdown(md).await;
6860        assert!(
6861            html.contains("language-rust"),
6862            "Rust block missing. Got: {}",
6863            html
6864        );
6865        assert!(
6866            html.contains("language-garbage_lang_404"),
6867            "Unsupported block missing. Got: {}",
6868            html
6869        );
6870        assert!(
6871            html.contains("language-python"),
6872            "Python block missing. Got: {}",
6873            html
6874        );
6875        assert!(html.contains("fn main"));
6876        assert!(html.contains("foo bar"));
6877        assert!(html.contains("print(1)"));
6878    }
6879
6880    // ==================== Comment-only YAML frontmatter ====================
6881
6882    #[test]
6883    fn yaml_loader_yields_no_documents_for_comment_only_frontmatter() {
6884        // Precondition for the two regressions below: yaml-rust2 parses a
6885        // comment-only block *successfully* but returns zero documents, so the
6886        // old `.map(|ys| ys[0].clone()).ok()` indexed out of bounds — and `.ok()`
6887        // cannot catch a panic. Release builds set `panic = 'abort'`, so one
6888        // user file with commented-out frontmatter SIGABRTed the process.
6889        assert!(
6890            YamlLoader::load_from_str("# tags: [draft]")
6891                .expect("comment-only YAML parses")
6892                .is_empty()
6893        );
6894        assert!(load_first_yaml_doc("# tags: [draft]").is_none());
6895    }
6896
6897    #[test]
6898    fn parse_survives_comment_only_frontmatter() {
6899        let mut file = NamedTempFile::new().unwrap();
6900        file.write_all(b"---\n# tags: [draft]\n---\n\nBody text.\n")
6901            .unwrap();
6902        let doc = parse(file.path()).expect("parse must not panic");
6903        assert!(
6904            doc.frontmatter.is_empty(),
6905            "comment-only frontmatter yields no metadata, got: {:?}",
6906            doc.frontmatter
6907        );
6908    }
6909
6910    #[test]
6911    fn extract_metadata_survives_comment_only_frontmatter() {
6912        let mut file = NamedTempFile::new().unwrap();
6913        file.write_all(b"---\n# tags: [draft]\n---\n\nBody text.\n")
6914            .unwrap();
6915        let meta = extract_metadata_from_file(file.path()).expect("must not panic");
6916        assert!(
6917            meta.metadata.is_empty(),
6918            "comment-only frontmatter yields no metadata, got: {:?}",
6919            meta.metadata
6920        );
6921        assert!(meta.relationships.is_empty());
6922    }
6923
6924    // ==================== UTF-8 BOM handling ====================
6925
6926    const BOM_DOC: &str = "\u{feff}---\ntitle: My Page\ntags:\n  - alpha\n---\n\nBody text.\n";
6927
6928    #[tokio::test]
6929    async fn bom_prefixed_frontmatter_still_renders_as_metadata() {
6930        let result = render_result(BOM_DOC).await;
6931        assert_eq!(
6932            result.frontmatter.get("title"),
6933            Some(&serde_json::Value::String("My Page".to_string())),
6934            "BOM suppressed the metadata block"
6935        );
6936        assert!(result.frontmatter.contains_key("tags"));
6937        assert!(
6938            !result.html.contains(EM_DASH),
6939            "frontmatter leaked into the body as an em-dash heading: {}",
6940            result.html
6941        );
6942    }
6943
6944    #[test]
6945    fn bom_prefixed_frontmatter_extracts_metadata() {
6946        let mut file = NamedTempFile::new().unwrap();
6947        file.write_all(BOM_DOC.as_bytes()).unwrap();
6948        let meta = extract_metadata_from_file(file.path()).unwrap();
6949        assert_eq!(
6950            meta.metadata.get("title"),
6951            Some(&serde_json::Value::String("My Page".to_string()))
6952        );
6953        assert!(meta.metadata.contains_key("tags"));
6954    }
6955
6956    #[test]
6957    fn bom_prefixed_frontmatter_parses() {
6958        let mut file = NamedTempFile::new().unwrap();
6959        file.write_all(BOM_DOC.as_bytes()).unwrap();
6960        let doc = parse(file.path()).unwrap();
6961        assert_eq!(
6962            doc.frontmatter.get("title"),
6963            Some(&serde_json::Value::String("My Page".to_string()))
6964        );
6965    }
6966
6967    #[test]
6968    fn strip_bom_leaves_bom_less_input_alone() {
6969        assert_eq!(strip_bom("# Heading"), "# Heading");
6970        assert_eq!(strip_bom("\u{feff}# Heading"), "# Heading");
6971        let mut owned = String::from("\u{feff}---\n");
6972        strip_bom_in_place(&mut owned);
6973        assert_eq!(owned, "---\n");
6974        let mut untouched = String::from("plain");
6975        strip_bom_in_place(&mut untouched);
6976        assert_eq!(untouched, "plain");
6977    }
6978
6979    // ==================== Code-block text transforms ====================
6980
6981    #[tokio::test]
6982    async fn vid_shortcode_inside_code_fence_is_not_expanded() {
6983        let html = render_result("```\n{{ vid(path=\"demo.mp4\") }}\n```\n")
6984            .await
6985            .html;
6986        assert!(
6987            !html.contains("<video"),
6988            "vid shortcode expanded inside a fence: {html}"
6989        );
6990        assert!(
6991            html.contains("vid(path="),
6992            "shortcode should render literally: {html}"
6993        );
6994    }
6995
6996    #[tokio::test]
6997    async fn bare_url_inside_code_fence_is_not_embedded() {
6998        let html = render_result("```\nhttps://example.com/in-code\n```\n")
6999            .await
7000            .html;
7001        // Assert on `<a href=` rather than the exact URL: the buggy path fed the
7002        // code text (trailing newline included) into `PageInfo::html()`, so the
7003        // emitted href was escaped and would not match the literal URL.
7004        assert!(
7005            !html.contains("<a href="),
7006            "bare URL linkified inside a fence: {html}"
7007        );
7008        assert!(
7009            html.contains("https://example.com/in-code"),
7010            "URL should render literally: {html}"
7011        );
7012    }
7013
7014    #[tokio::test]
7015    async fn canceled_checkbox_marker_inside_code_fence_is_not_transformed() {
7016        let html = render_result("```\n[-] not a checkbox\n```\n").await.html;
7017        assert!(
7018            !html.contains("mbr-task-check"),
7019            "checkbox transform fired inside a fence: {html}"
7020        );
7021        assert!(
7022            html.contains("[-] not a checkbox"),
7023            "line should render literally: {html}"
7024        );
7025    }
7026
7027    #[tokio::test]
7028    async fn text_transforms_still_apply_outside_code_fences() {
7029        let vid_html = render_result("{{ vid(path=\"demo.mp4\") }}").await.html;
7030        assert!(
7031            vid_html.contains("<video"),
7032            "vid shortcode outside a fence must expand: {vid_html}"
7033        );
7034
7035        let url_html = render_result("https://example.com/outside").await.html;
7036        assert!(
7037            url_html.contains("<a href=\"https://example.com/outside\""),
7038            "bare URL outside a fence must still be linkified: {url_html}"
7039        );
7040
7041        let todo_html = render_result("- [-] canceled task").await.html;
7042        assert!(
7043            todo_html.contains("mbr-task-check"),
7044            "checkbox transform must still work outside a fence: {todo_html}"
7045        );
7046    }
7047
7048    #[test]
7049    fn collect_bare_urls_skips_code_blocks() {
7050        // A URL that only appears in a code sample must never trigger an
7051        // outbound HTTP request.
7052        let (fenced, _, _) = collect_events_and_headings(
7053            "```\nhttps://example.com/in-code\n```\n",
7054            TaskMarkup::Skip,
7055            &mut TextLines::disabled(),
7056            &mut BlockLines::disabled(),
7057        );
7058        assert!(
7059            collect_bare_urls(&fenced).is_empty(),
7060            "code-block URLs must not be queued for fetching"
7061        );
7062
7063        let (prose, _, _) = collect_events_and_headings(
7064            "https://example.com/outside\n",
7065            TaskMarkup::Skip,
7066            &mut TextLines::disabled(),
7067            &mut BlockLines::disabled(),
7068        );
7069        assert_eq!(
7070            collect_bare_urls(&prose).len(),
7071            1,
7072            "prose URLs must still be queued"
7073        );
7074    }
7075
7076    // ==================== Async/sync embed parity ====================
7077
7078    #[tokio::test]
7079    async fn local_embeds_render_at_timeout_zero_in_both_paths() {
7080        // No-network embeds (YouTube here) must survive `oembed_timeout_ms = 0`;
7081        // QuickLook hardcodes 0 into the async path and builds default to it.
7082        let mut file = NamedTempFile::new().unwrap();
7083        file.write_all(b"https://youtu.be/dQw4w9WgXcQ\n").unwrap();
7084        let path = file.path().to_path_buf();
7085        let root = path.parent().unwrap().to_path_buf();
7086        let config = LinkTransformConfig {
7087            markdown_extensions: vec!["md".to_string()],
7088            index_file: "index.md".to_string(),
7089            is_index_file: false,
7090            url_depth: None,
7091            current_page_url: String::new(),
7092            markdown_page_probe: None,
7093        };
7094
7095        let async_result = render_with_cache(
7096            path.clone(),
7097            &root,
7098            0,
7099            config.clone(),
7100            None,
7101            false,
7102            false,
7103            HashSet::new(),
7104            ReviewLines::Omit,
7105            false,
7106            &[],
7107            None,
7108        )
7109        .await
7110        .unwrap();
7111        let sync_result = render_sync(
7112            path,
7113            &root,
7114            0,
7115            config,
7116            None,
7117            false,
7118            false,
7119            HashSet::new(),
7120            ReviewLines::Omit,
7121            false,
7122            &[],
7123            None,
7124        )
7125        .unwrap();
7126
7127        assert!(
7128            async_result.html.contains("youtube-embed"),
7129            "async path dropped the no-network embed: {}",
7130            async_result.html
7131        );
7132        assert_eq!(
7133            async_result.html, sync_result.html,
7134            "async and sync render paths must agree at oembed_timeout_ms = 0"
7135        );
7136    }
7137
7138    // ==================== Heading text extraction ====================
7139
7140    #[tokio::test]
7141    async fn heading_text_and_anchor_include_inline_code() {
7142        let result = render_result("## The `main` function\n").await;
7143        assert_eq!(result.headings[0].text, "The main function");
7144        assert_eq!(result.headings[0].id, "the-main-function");
7145    }
7146
7147    #[test]
7148    fn extract_first_h1_includes_inline_code() {
7149        assert_eq!(
7150            extract_first_h1("# The `main` function\n"),
7151            Some("The main function".to_string())
7152        );
7153    }
7154
7155    #[tokio::test]
7156    async fn heading_text_excludes_raw_inline_html() {
7157        // Deliberate: the raw `<kbd>` markup is not readable heading text, and
7158        // its inner text already arrives as a separate Text event.
7159        let result = render_result("## Press <kbd>Ctrl</kbd> now\n").await;
7160        assert_eq!(result.headings[0].text, "Press Ctrl now");
7161        assert_eq!(result.headings[0].id, "press-ctrl-now");
7162    }
7163
7164    // ==================== Slugification ====================
7165
7166    #[test]
7167    fn slugify_lowercases_and_separates_words() {
7168        assert_eq!(slugify("Hello World"), "hello-world");
7169        assert_eq!(slugify("Meeting Notes"), "meeting-notes");
7170        assert_eq!(slugify("Ünïcode Heading"), "ünïcode-heading");
7171        assert_eq!(slugify("already-slugged"), "already-slugged");
7172    }
7173
7174    /// Trailing punctuation vanishes cleanly; punctuation *between* words
7175    /// yields a doubled dash. Pinned rather than endorsed: heading anchors in
7176    /// existing repositories link to these exact slugs.
7177    #[test]
7178    fn slugify_drops_punctuation() {
7179        assert_eq!(slugify("Field Note!"), "field-note");
7180        assert_eq!(slugify("Hello, World!"), "hello--world");
7181    }
7182
7183    /// `slugify` itself has no fallback — that belongs to callers, and
7184    /// `generate_anchor_id`'s `heading` default must not leak into body classes.
7185    #[test]
7186    fn slugify_returns_empty_for_nothing_to_keep() {
7187        assert_eq!(slugify(""), "");
7188        assert_eq!(slugify("!!!"), "");
7189    }
7190
7191    // ==================== Anchor id generation ====================
7192
7193    #[test]
7194    fn generate_anchor_id_basic_slugification() {
7195        let mut anchor_ids = HashMap::new();
7196        assert_eq!(
7197            generate_anchor_id("Hello World", &mut anchor_ids),
7198            "hello-world"
7199        );
7200        assert_eq!(
7201            generate_anchor_id("Ünïcode Heading", &mut anchor_ids),
7202            "ünïcode-heading"
7203        );
7204        // Punctuation is dropped and can leave a doubled separator. Recorded as
7205        // the slugifier's current behavior, not endorsed as a good slug —
7206        // changing it would break every existing hand-written `#anchor` link.
7207        assert_eq!(
7208            generate_anchor_id("Hello, World!", &mut anchor_ids),
7209            "hello--world"
7210        );
7211    }
7212
7213    #[test]
7214    fn generate_anchor_id_never_collides() {
7215        // Regression: the old per-base counter emitted `step-1-2` for both the
7216        // second "Step 1" and for "Step 1-2".
7217        let mut anchor_ids = HashMap::new();
7218        let headings = ["Step 1", "Step 1", "Step 1-2", "", "", "Ünïcode Ünïcode"];
7219        let ids: Vec<String> = headings
7220            .iter()
7221            .map(|h| generate_anchor_id(h, &mut anchor_ids))
7222            .collect();
7223
7224        assert_eq!(
7225            ids.iter().collect::<HashSet<_>>().len(),
7226            ids.len(),
7227            "duplicate anchor ids: {ids:?}"
7228        );
7229        assert_eq!(ids[0], "step-1");
7230        assert_eq!(ids[1], "step-1-2");
7231        assert_eq!(ids[3], "heading");
7232        assert_eq!(ids[4], "heading-2");
7233    }
7234
7235    // ==================== Bounded oembed fan-out ====================
7236
7237    #[test]
7238    fn cap_fetch_list_limits_and_is_deterministic() {
7239        let urls: Vec<String> = (0..150)
7240            .map(|i| format!("https://example.com/{i:03}"))
7241            .collect();
7242
7243        let mut expected = urls.clone();
7244        expected.sort_unstable();
7245        expected.truncate(MAX_OEMBED_FETCHES_PER_DOC);
7246
7247        assert_eq!(
7248            cap_fetch_list(urls.clone()).len(),
7249            MAX_OEMBED_FETCHES_PER_DOC
7250        );
7251        assert_eq!(cap_fetch_list(urls.clone()), expected);
7252
7253        // Truncation must not depend on iteration order (collect_bare_urls
7254        // returns a HashSet, whose order varies per process).
7255        let reversed: Vec<String> = urls.into_iter().rev().collect();
7256        assert_eq!(cap_fetch_list(reversed), expected);
7257    }
7258
7259    #[test]
7260    fn cap_fetch_list_leaves_small_lists_untouched() {
7261        let urls = vec![
7262            "https://b.example".to_string(),
7263            "https://a.example".to_string(),
7264        ];
7265        assert_eq!(
7266            cap_fetch_list(urls.clone()),
7267            urls,
7268            "lists under the cap must be passed through unchanged"
7269        );
7270    }
7271
7272    #[tokio::test]
7273    async fn prefetch_oembed_urls_resolves_local_embeds_without_network() {
7274        // Every URL here short-circuits in `PageInfo::new_from_url` via the
7275        // no-network embed path, so this exercises the bounded stream with no I/O.
7276        let md = "https://youtu.be/aaaaaaaaaaa\n\nhttps://youtu.be/bbbbbbbbbbb\n\nhttps://youtu.be/ccccccccccc\n";
7277        let (events, _, _) = collect_events_and_headings(
7278            md,
7279            TaskMarkup::Skip,
7280            &mut TextLines::disabled(),
7281            &mut BlockLines::disabled(),
7282        );
7283        let results = prefetch_oembed_urls(&events, 500, &None).await;
7284        assert_eq!(results.len(), 3);
7285        assert!(results.values().all(|info| info.embed_html.is_some()));
7286    }
7287
7288    // ---- data-mbr-line (review anchors) --------------------------------------
7289
7290    /// Runs pass 1 only and returns the events plus the recorded
7291    /// `(event index, source line)` block pairs.
7292    ///
7293    /// Both tables record, which is the default server configuration
7294    /// (`review_enabled` and `mark_incomplete` are both on). It also matters for
7295    /// the rewrites: a synthesized block start inherits a line from an event
7296    /// it replaces, and a line is only computed when some table asks for it.
7297    /// [`render_review_with`] covers the configurations where only one does.
7298    fn pass_one<'a>(md: &'a str) -> (Vec<Event<'a>>, Vec<(u32, u32)>) {
7299        let mut block_lines = BlockLines::recording();
7300        let (events, _, _) = collect_events_and_headings(
7301            md,
7302            TaskMarkup::Render,
7303            &mut TextLines::recording(),
7304            &mut block_lines,
7305        );
7306        let recorded = block_lines
7307            .into_entries()
7308            .into_iter()
7309            .map(|entry| (entry.at, entry.line))
7310            .collect();
7311        (events, recorded)
7312    }
7313
7314    /// [`pass_one`], keeping only the block records.
7315    fn recorded_block_lines(md: &str) -> Vec<(u32, u32)> {
7316        pass_one(md).1
7317    }
7318
7319    /// Renders `md` through the async entry point with `data-mbr-line` on, and
7320    /// optionally with incomplete-marker highlighting.
7321    async fn render_review(md: &str, mark_incomplete: bool) -> String {
7322        render_review_with(md, mark_incomplete, ReviewLines::Emit).await
7323    }
7324
7325    /// [`render_review`] with an explicit [`ReviewLines`] mode.
7326    async fn render_review_with(md: &str, mark_incomplete: bool, review: ReviewLines) -> String {
7327        let mut file = NamedTempFile::new().unwrap();
7328        file.write_all(md.as_bytes()).unwrap();
7329        let path = file.path().to_path_buf();
7330        let root = path.parent().unwrap().to_path_buf();
7331        let markers: Vec<String> = DEFAULT_MARKERS.iter().map(|m| m.to_string()).collect();
7332        render(
7333            path,
7334            &root,
7335            0,
7336            LinkTransformConfig {
7337                markdown_extensions: vec!["md".to_string()],
7338                index_file: "index.md".to_string(),
7339                is_index_file: false,
7340                url_depth: None,
7341                current_page_url: String::new(),
7342                markdown_page_probe: None,
7343            },
7344            false,
7345            false,
7346            HashSet::new(),
7347            review,
7348            mark_incomplete,
7349            &markers,
7350            None,
7351        )
7352        .await
7353        .unwrap()
7354        .html
7355    }
7356
7357    /// Every `data-mbr-line` value in `html`, in document order.
7358    fn emitted_block_lines(html: &str) -> Vec<u32> {
7359        const ATTR: &str = "data-mbr-line=\"";
7360        html.match_indices(ATTR)
7361            .map(|(at, _)| {
7362                let rest = &html[at + ATTR.len()..];
7363                rest[..rest.find('"').expect("unterminated data-mbr-line")]
7364                    .parse()
7365                    .expect("numeric line")
7366            })
7367            .collect()
7368    }
7369
7370    /// The `data-mbr-line` on the first tag in `html` starting with `prefix`
7371    /// (e.g. `"<pre"`), or `None` if that tag carries none.
7372    fn block_line_of_tag(html: &str, prefix: &str) -> Option<u32> {
7373        let open = html
7374            .find(prefix)
7375            .unwrap_or_else(|| panic!("{prefix:?} not in: {html}"));
7376        let close = open + html[open..].find('>').expect("a closed tag");
7377        let tag = &html[open..close];
7378        const ATTR: &str = "data-mbr-line=\"";
7379        let value_at = tag.find(ATTR)? + ATTR.len();
7380        let rest = &tag[value_at..];
7381        Some(
7382            rest[..rest.find('"').expect("unterminated")]
7383                .parse()
7384                .expect("numeric line"),
7385        )
7386    }
7387
7388    /// The `data-mbr-line` carried by the first element whose opening tag
7389    /// contains `needle`, or `None` if that element carries none.
7390    fn block_line_of(html: &str, needle: &str) -> Option<u32> {
7391        let at = html
7392            .find(needle)
7393            .unwrap_or_else(|| panic!("{needle:?} not in: {html}"));
7394        // Back up to the `<` that opens the tag `needle` sits in.
7395        let open = html[..at].rfind('<').expect("an opening tag");
7396        let close = open + html[open..].find('>').expect("a closed tag");
7397        let tag = &html[open..close];
7398        const ATTR: &str = "data-mbr-line=\"";
7399        let value_at = tag.find(ATTR)? + ATTR.len();
7400        let rest = &tag[value_at..];
7401        Some(
7402            rest[..rest.find('"').expect("unterminated")]
7403                .parse()
7404                .expect("numeric line"),
7405        )
7406    }
7407
7408    #[test]
7409    fn block_lines_records_the_six_block_starts() {
7410        // Line-by-line, with the block starts each line produces:
7411        //   1  `# H`         Start(Heading)
7412        //   3  `Para.`       Start(Paragraph)
7413        //   5  `- item`      Start(List) — not recorded — then Start(Item)
7414        //   7  `> quote`     Start(BlockQuote), then its inner Start(Paragraph)
7415        //   9  ```` ``` ```` Start(CodeBlock)
7416        //   13 `| A |`       Start(Table); its Start(TableCell)s are not recorded
7417        let md = concat!(
7418            "# H\n\n",             // 1
7419            "Para.\n\n",           // 3
7420            "- item\n\n",          // 5
7421            "> quote\n\n",         // 7
7422            "```\ncode\n```\n\n",  // 9-11
7423            "| A |\n|---|\n| b |", // 13-15
7424        );
7425        let recorded = recorded_block_lines(md);
7426        let lines: Vec<u32> = recorded.iter().map(|(_, line)| *line).collect();
7427        assert_eq!(
7428            lines,
7429            vec![1, 3, 5, 7, 7, 9, 13],
7430            "heading, paragraph, item, blockquote + its inner paragraph, code \
7431             block, table: {recorded:?}"
7432        );
7433
7434        // The exact slots, so a change in what pass 1 emits cannot go unnoticed.
7435        assert_eq!(
7436            recorded,
7437            vec![(0, 1), (3, 3), (7, 5), (11, 7), (12, 7), (16, 9), (19, 13)],
7438            "recorded (event index, source line) pairs"
7439        );
7440
7441        // `<ul>` and `<td>` are deliberately not in the set.
7442        assert_eq!(
7443            recorded.len(),
7444            7,
7445            "no record for Tag::List or Tag::TableCell: {recorded:?}"
7446        );
7447    }
7448
7449    /// Every `data-mbr-line` value on an opening `tag` (e.g. `"<dt"`), in
7450    /// document order.
7451    fn block_lines_on(html: &str, tag: &str) -> Vec<u32> {
7452        html.match_indices(tag)
7453            .filter_map(|(at, _)| block_line_of_tag(&html[at..], tag))
7454            .collect()
7455    }
7456
7457    /// `<dt>`/`<dd>` carry their own source line — the flashcard writer
7458    /// addresses a card by its term's line, and the frontend reads it from the
7459    /// `<dt>`. Covers frontmatter (lines are file lines, not body lines), tight
7460    /// and loose lists, multiple answers, and an image in an earlier term: the
7461    /// writer's second event loop (`raw_text`, draining alt text) must not
7462    /// desynchronise the index for the terms after it.
7463    #[tokio::test]
7464    async fn block_lines_on_definition_terms_and_definitions() {
7465        let md = concat!(
7466            "---\n",                      // 1
7467            "type: flashcard\n",          // 2
7468            "---\n",                      // 3
7469            "\n",                         // 4
7470            "![a *styled* alt](x.png)\n", // 5  term with an image
7471            ": first answer\n",           // 6
7472            ": second answer\n",          // 7
7473            "\n",                         // 8
7474            "Loose term\n",               // 9
7475            "\n",                         // 10
7476            ": loose answer\n",           // 11
7477            "\n",                         // 12
7478            "Last term\n",                // 13
7479            ": last answer\n",            // 14
7480        );
7481        let html = render_review(md, false).await;
7482        assert_eq!(block_lines_on(&html, "<dt"), vec![5, 9, 13], "{html}");
7483        assert_eq!(block_lines_on(&html, "<dd"), vec![6, 7, 11, 14], "{html}");
7484        // The FAQ disclosure's `tabindex` survives alongside the attribute.
7485        assert!(
7486            html.contains(r#"<dt data-mbr-line="9" tabindex="0">"#),
7487            "{html}"
7488        );
7489    }
7490
7491    /// A definition list inside a ```` ```chat ```` body is spliced into the
7492    /// page stream by `push_chat_markdown`, which consults the same
7493    /// `is_review_block_start`, so its `<dt>`/`<dd>` carry their real source
7494    /// lines too, and the table stays strictly ascending across the boundary
7495    /// between page events and chat-body events. (The deck still ignores such
7496    /// a list: `flashcards/dom.ts` treats `.mbr-chat` as nesting, matching the
7497    /// writer, which sees a code fence there and refuses the line.)
7498    #[tokio::test]
7499    async fn block_lines_on_definition_lists_inside_chat_bodies() {
7500        let md = concat!(
7501            "Before term\n",       // 1
7502            ": before answer\n",   // 2
7503            "\n",                  // 3
7504            "```chat\n",           // 4
7505            "{{Alice|Chat term\n", // 5
7506            ": chat answer\n",     // 6
7507            "|}}\n",               // 7
7508            "Between term\n",      // 8
7509            ": between answer\n",  // 9
7510            "```\n",               // 10
7511            "\n",                  // 11
7512            "After term\n",        // 12
7513            ": after answer\n",    // 13
7514        );
7515        let html = render_review(md, false).await;
7516        assert!(html.contains("<div class=\"mbr-chat\""), "{html}");
7517        assert_eq!(block_lines_on(&html, "<dt"), vec![1, 5, 8, 12], "{html}");
7518        assert_eq!(block_lines_on(&html, "<dd"), vec![2, 6, 9, 13], "{html}");
7519
7520        let emitted = emitted_block_lines(&html);
7521        assert!(
7522            emitted.windows(2).all(|pair| pair[0] <= pair[1]),
7523            "data-mbr-line values follow document order: {emitted:?}"
7524        );
7525
7526        let recorded = recorded_block_lines(md);
7527        assert!(
7528            recorded.windows(2).all(|pair| pair[0].0 < pair[1].0),
7529            "BlockLines records must be strictly ascending by event index: {recorded:?}"
7530        );
7531    }
7532
7533    #[test]
7534    fn block_lines_disabled_records_nothing() {
7535        let md = "# H\n\nPara.\n\n- item\n\n> quote\n";
7536        let mut block_lines = BlockLines::disabled();
7537        let _ = collect_events_and_headings(
7538            md,
7539            TaskMarkup::Render,
7540            &mut TextLines::disabled(),
7541            &mut block_lines,
7542        );
7543        assert!(block_lines.into_entries().is_empty());
7544    }
7545
7546    /// Both pass-1 rewrites pop a *recorded* `Start(Tag::Paragraph)` and put
7547    /// something else in its slot. Without the truncation in `pop_event`, the
7548    /// remark-hint case re-records the vacated slot and trips
7549    /// `BlockLines::record`'s strictly-ascending assertion, while the rule-attrs
7550    /// case leaves a record addressing whatever later refills the slot.
7551    #[test]
7552    fn pop_event_truncates_block_lines() {
7553        // Rule-attrs: `--- {#intro}` reaches pass 1 as a paragraph and leaves as
7554        // an `Event::Rule`, which is not a recorded block at all.
7555        let (events, recorded) = pass_one("Before.\n\n--- {#intro}\n\nAfter.\n");
7556        assert!(
7557            events.iter().any(|e| matches!(e, Event::Rule)),
7558            "the rewrite really did fire"
7559        );
7560        assert_eq!(
7561            recorded.iter().map(|(_, l)| *l).collect::<Vec<_>>(),
7562            vec![1, 5],
7563            "only the two real paragraphs; the rule is not a recorded block: \
7564             {recorded:?}"
7565        );
7566        for (at, _) in &recorded {
7567            assert!(
7568                matches!(events[*at as usize], Event::Start(Tag::Paragraph)),
7569                "record {at} must still address a paragraph, not {:?}",
7570                events[*at as usize]
7571            );
7572        }
7573
7574        // Remark hints: `!> tip` swaps the `Start(Paragraph)` for a
7575        // `Start(BlockQuote)` — another *recorded* block, reusing the same slot.
7576        // This is the case that would trip the ascending assertion.
7577        let (events, recorded) = pass_one("Intro.\n\n!> a tip\n\nAfter.\n");
7578        assert!(
7579            events
7580                .iter()
7581                .any(|e| matches!(e, Event::Start(Tag::BlockQuote(Some(_))))),
7582            "the rewrite really did fire"
7583        );
7584        assert_eq!(
7585            recorded.iter().map(|(_, l)| *l).collect::<Vec<_>>(),
7586            vec![1, 3, 3, 5],
7587            "intro paragraph, the hint's blockquote and its paragraph, after: \
7588             {recorded:?}"
7589        );
7590        for (at, _) in &recorded {
7591            assert!(
7592                is_review_block_start(&events[*at as usize]),
7593                "record {at} must address a block start, not {:?}",
7594                events[*at as usize]
7595            );
7596        }
7597    }
7598
7599    /// The gotcha this whole remap exists for. `mark_incomplete_blocks` inserts
7600    /// a wash span behind an *already emitted* `Start`, which shifts every
7601    /// output index recorded after it. Removing the `shift_from` call in
7602    /// `mark_incomplete_blocks` makes this test fail.
7603    ///
7604    /// # Why this fixture
7605    ///
7606    /// The insert lands at `frame.start_idx + 1`, so it only shifts anything
7607    /// when the frame's start is *not* the last thing in `output` — i.e. when a
7608    /// complete nested block sits between the frame's `Start` and its own first
7609    /// text. A nested **list** does not produce that: `- - inner` followed by an
7610    /// indented line makes the second line a lazy continuation of the *inner*
7611    /// item, and separating them with a blank line wraps the outer text in its
7612    /// own `Paragraph` frame, whose insert is an append. A fenced code block in
7613    /// a **tight** list item is the shape that does it: the item's first text
7614    /// arrives after `End(CodeBlock)`, with the item frame still text-less, so
7615    /// the span is inserted behind the code block's already-recorded slot.
7616    #[tokio::test]
7617    async fn block_lines_survive_pass_three_insertion() {
7618        let md = concat!(
7619            "- ```\n",           // 1  item + code fence open
7620            "  code\n",          // 2
7621            "  ```\n",           // 3
7622            "  TODO after\n\n",  // 4  the item's own first text
7623            "Later paragraph\n", // 6
7624        );
7625        let html = render_review(md, true).await;
7626
7627        assert!(
7628            html.contains(INCOMPLETE_SPAN_PREFIX),
7629            "the insertion really did happen: {html}"
7630        );
7631        // Recorded before the insert, and therefore the record that needs the
7632        // `+1`. Without `shift_from` it addresses the inserted span instead —
7633        // an `Event::Html`, which no writer arm asks for a line — so the `<pre>`
7634        // silently loses its attribute.
7635        assert_eq!(
7636            block_line_of_tag(&html, "<pre"),
7637            Some(1),
7638            "the code block opens on line 1: {html}"
7639        );
7640        assert_eq!(
7641            block_line_of_tag(&html, "<li"),
7642            Some(1),
7643            "the list item opens on line 1: {html}"
7644        );
7645        // Recorded after the insert, so it is right either way; it is here to
7646        // show `shift_from` does not over-correct.
7647        assert_eq!(
7648            block_line_of(&html, "Later paragraph"),
7649            Some(6),
7650            "the later paragraph is on line 6: {html}"
7651        );
7652    }
7653
7654    /// The other, far commoner pass-3 shape: `push_marked_text` turns one text
7655    /// run into three events. Those are pushes, not inserts, so nothing needs
7656    /// shifting — but the remap has to key off `output.len()` for that to hold.
7657    #[tokio::test]
7658    async fn block_lines_survive_pass_three_span_pushes() {
7659        let md = concat!(
7660            "Alpha with a TK mid-run and another TK too.\n\n", // 1
7661            "Beta with a FIXME mid-run.\n\n",                  // 3
7662            "# Gamma has an XXX in it\n\n",                    // 5
7663            "Delta is plain.\n",                               // 7
7664        );
7665        let html = render_review(md, true).await;
7666        assert_eq!(
7667            emitted_block_lines(&html),
7668            vec![1, 3, 5, 7],
7669            "one attribute per block, at its true line: {html}"
7670        );
7671        assert_eq!(block_line_of(&html, "Delta is plain."), Some(7), "{html}");
7672    }
7673
7674    /// `--no-review` with editing on: the flashcard writer still needs every
7675    /// term's line, and nothing else may be numbered. The fixture has every
7676    /// other recorded block kind, a ```` ```chat ```` block (whose container
7677    /// carries a line of its own under [`ReviewLines::Emit`]) and markers in a
7678    /// definition and an item, so pass 3's remap runs over a terms-only table.
7679    #[tokio::test]
7680    async fn terms_only_numbers_definition_terms_and_nothing_else() {
7681        let md = concat!(
7682            "# Heading\n\n",         // 1
7683            "A paragraph.\n\n",      // 3
7684            "- TODO an item\n\n",    // 5
7685            "> a quote\n\n",         // 7
7686            "First term\n",          // 9
7687            ": TODO first answer\n", // 10
7688            "\n",                    // 11
7689            "Loose term\n\n",        // 12
7690            ": loose answer\n\n",    // 14
7691            "```chat\n",             // 16
7692            "{{Alice|Chat term\n",   // 17
7693            ": chat answer\n",       // 18
7694            "|}}\n",                 // 19
7695            "```\n\n",               // 20
7696            "```\ncode\n```\n",      // 22
7697        );
7698        for mark_incomplete in [false, true] {
7699            let html = render_review_with(md, mark_incomplete, ReviewLines::TermsOnly).await;
7700            assert_eq!(block_lines_on(&html, "<dt"), vec![9, 12, 17], "{html}");
7701            assert_eq!(
7702                emitted_block_lines(&html),
7703                vec![9, 12, 17],
7704                "terms only, mark_incomplete={mark_incomplete}: {html}"
7705            );
7706            if mark_incomplete {
7707                assert!(html.contains(INCOMPLETE_SPAN_PREFIX), "{html}");
7708            }
7709        }
7710    }
7711
7712    /// A terms-only render of a page with no definition list is the
7713    /// feature-off render, byte for byte; with one, it differs only by the
7714    /// terms' attributes.
7715    #[tokio::test]
7716    async fn terms_only_differs_from_omit_only_on_terms() {
7717        let plain = "# H\n\nA TODO paragraph.\n\n- item\n";
7718        assert_eq!(
7719            render_review_with(plain, true, ReviewLines::TermsOnly).await,
7720            render_review_with(plain, true, ReviewLines::Omit).await,
7721        );
7722
7723        let deck = "Term\n: answer\n\nOther\n: TODO other answer\n";
7724        let terms = render_review_with(deck, true, ReviewLines::TermsOnly).await;
7725        let omit = render_review_with(deck, true, ReviewLines::Omit).await;
7726        assert_eq!(
7727            terms
7728                .replace(" data-mbr-line=\"1\"", "")
7729                .replace(" data-mbr-line=\"4\"", ""),
7730            omit,
7731        );
7732    }
7733
7734    #[test]
7735    fn review_lines_for_server_modes() {
7736        assert_eq!(ReviewLines::for_server(true, false), ReviewLines::Emit);
7737        assert_eq!(ReviewLines::for_server(true, true), ReviewLines::Emit);
7738        assert_eq!(ReviewLines::for_server(false, true), ReviewLines::TermsOnly);
7739        assert_eq!(ReviewLines::for_server(false, false), ReviewLines::Omit);
7740    }
7741
7742    /// Regression: a remark-hint alert took its block lines from the hint
7743    /// `Text` event's line, which is computed only when `TextLines` records —
7744    /// so with `--no-mark-incomplete` and review on, both the alert's
7745    /// `<blockquote>` and its `<p>` lost `data-mbr-line` and review notes could
7746    /// not anchor inside tips, warnings or cautions. The two gates must stay
7747    /// independent: the output is the same whether markers are on or off.
7748    #[tokio::test]
7749    async fn remark_hint_lines_survive_no_mark_incomplete() {
7750        for (prefix, class) in [
7751            ("!>", "markdown-alert-tip"),
7752            ("?>", "markdown-alert-warning"),
7753            ("x>", "markdown-alert-caution"),
7754        ] {
7755            let md = format!("Intro.\n\n{prefix} an alert here\n\nAfter.\n");
7756            let review = ReviewLines::for_server(true, false);
7757            let off = render_review_with(&md, false, review).await;
7758            let on = render_review_with(&md, true, review).await;
7759            let expected = format!(
7760                r#"<blockquote class="{class}" data-mbr-line="3">
7761<p data-mbr-line="3">"#
7762            );
7763            assert!(
7764                off.contains(&expected),
7765                "{prefix} with markers off: expected {expected} in {off}"
7766            );
7767            assert!(
7768                on.contains(&expected),
7769                "{prefix} with markers on: expected {expected} in {on}"
7770            );
7771            assert_eq!(emitted_block_lines(&off), vec![1, 3, 3, 5], "{off}");
7772            assert_eq!(off, on, "markers must not change review numbering");
7773        }
7774    }
7775
7776    /// The hint fix reads the replaced paragraph's record, and a terms-only
7777    /// table never records a paragraph — so the alert stays unnumbered there,
7778    /// in either marker mode, exactly like the `Omit` render.
7779    #[tokio::test]
7780    async fn remark_hint_terms_only_numbers_nothing() {
7781        let md = "Intro.\n\n!> a tip\n\nTerm\n: answer\n";
7782        for mark_incomplete in [false, true] {
7783            let terms = render_review_with(md, mark_incomplete, ReviewLines::TermsOnly).await;
7784            let omit = render_review_with(md, mark_incomplete, ReviewLines::Omit).await;
7785            assert_eq!(emitted_block_lines(&terms), vec![5], "{terms}");
7786            assert_eq!(terms.replace(" data-mbr-line=\"5\"", ""), omit);
7787        }
7788    }
7789
7790    /// With the feature off the writer must produce exactly the bytes it
7791    /// produced before `data-mbr-line` existed.
7792    #[tokio::test]
7793    async fn review_off_is_byte_identical() {
7794        let md = concat!(
7795            "---\ntitle: Fixture\n---\n\n",
7796            "# Heading {#custom}\n\n",
7797            "A paragraph with *emphasis*, `code` and a [link](other.md).\n\n",
7798            "> A quote\n>\n> with two paragraphs.\n\n",
7799            "- one\n- two\n  - nested\n\n",
7800            "1. first\n2. second\n\n",
7801            "- [ ] a task @due(2026-08-05)\n\n",
7802            "```rust\nfn main() {}\n```\n\n",
7803            "```mermaid\ngraph TD\n```\n\n",
7804            "    indented code\n\n",
7805            "| A | B |\n|---|---|\n| 1 | 2 |\n\n",
7806            "![alt with *markup*](img.png)\n\n",
7807            "--- {#sec .highlight}\n\n",
7808            "!> a tip\n\n",
7809            "Term\n: Definition\n\n",
7810            "Final paragraph.\n",
7811        );
7812        let with = render_review(md, false).await;
7813        let without = {
7814            let mut file = NamedTempFile::new().unwrap();
7815            file.write_all(md.as_bytes()).unwrap();
7816            let path = file.path().to_path_buf();
7817            let root = path.parent().unwrap().to_path_buf();
7818            render(
7819                path,
7820                &root,
7821                0,
7822                LinkTransformConfig {
7823                    markdown_extensions: vec!["md".to_string()],
7824                    index_file: "index.md".to_string(),
7825                    is_index_file: false,
7826                    url_depth: None,
7827                    current_page_url: String::new(),
7828                    markdown_page_probe: None,
7829                },
7830                false,
7831                false,
7832                HashSet::new(),
7833                ReviewLines::Omit,
7834                false,
7835                &[],
7836                None,
7837            )
7838            .await
7839            .unwrap()
7840            .html
7841        };
7842
7843        assert!(
7844            with.contains("data-mbr-line="),
7845            "the Emit render really did emit some: {with}"
7846        );
7847        let stripped = strip_block_line_attrs(&with);
7848        assert_eq!(
7849            stripped, without,
7850            "stripping the attribute must recover the Omit output exactly"
7851        );
7852    }
7853
7854    /// Removes every ` data-mbr-line="N"` occurrence, the regex-free equivalent
7855    /// of `/ data-mbr-line="\d+"/g`.
7856    fn strip_block_line_attrs(html: &str) -> String {
7857        const ATTR: &str = " data-mbr-line=\"";
7858        let mut out = String::with_capacity(html.len());
7859        let mut rest = html;
7860        while let Some(at) = rest.find(ATTR) {
7861            out.push_str(&rest[..at]);
7862            let after = &rest[at + ATTR.len()..];
7863            let end = after.find('"').expect("unterminated data-mbr-line");
7864            assert!(
7865                after[..end].chars().all(|c| c.is_ascii_digit()),
7866                "data-mbr-line must hold digits only, got {:?}",
7867                &after[..end]
7868            );
7869            rest = &after[end + 1..];
7870        }
7871        out.push_str(rest);
7872        out
7873    }
7874
7875    /// The two entry points duplicate the whole pipeline, so they have to be
7876    /// shown to agree rather than assumed to — as with marker anchors.
7877    #[tokio::test]
7878    async fn sync_and_async_agree_with_review_on() {
7879        let md = concat!(
7880            "---\ntitle: T\n---\n\n",
7881            "# TODO heading\n\n",
7882            "Prose with a TK in the middle.\n\n",
7883            "- [ ] TODO: a task\n",
7884            "- - inner\n  TODO outer\n\n",
7885            "> quoted TK\n\n",
7886            "```\nTK in code\n```\n\n",
7887            "| A | B |\n|---|---|\n| TK a | TK b |\n",
7888        );
7889        let mut file = NamedTempFile::new().unwrap();
7890        file.write_all(md.as_bytes()).unwrap();
7891        let path = file.path().to_path_buf();
7892        let root = path.parent().unwrap().to_path_buf();
7893        let config = LinkTransformConfig {
7894            markdown_extensions: vec!["md".to_string()],
7895            index_file: "index.md".to_string(),
7896            is_index_file: false,
7897            url_depth: None,
7898            current_page_url: String::new(),
7899            markdown_page_probe: None,
7900        };
7901        let markers: Vec<String> = DEFAULT_MARKERS.iter().map(|m| m.to_string()).collect();
7902
7903        let async_html = render(
7904            path.clone(),
7905            &root,
7906            0,
7907            config.clone(),
7908            false,
7909            false,
7910            HashSet::new(),
7911            ReviewLines::Emit,
7912            true,
7913            &markers,
7914            None,
7915        )
7916        .await
7917        .unwrap()
7918        .html;
7919        let sync_html = render_sync(
7920            path,
7921            &root,
7922            0,
7923            config,
7924            None,
7925            false,
7926            false,
7927            HashSet::new(),
7928            ReviewLines::Emit,
7929            true,
7930            &markers,
7931            None,
7932        )
7933        .unwrap()
7934        .html;
7935
7936        assert_eq!(async_html, sync_html);
7937        assert!(
7938            !emitted_block_lines(&async_html).is_empty(),
7939            "the fixture really does carry attributes: {async_html}"
7940        );
7941    }
7942
7943    #[tokio::test]
7944    async fn every_emitted_line_is_in_range() {
7945        let md = concat!(
7946            "---\ntitle: T\n---\n\n",
7947            "# Heading\n\n",
7948            "Paragraph one.\n\n",
7949            "> quote\n\n",
7950            "- item\n- item two\n\n",
7951            "```\ncode\n```\n\n",
7952            "| A |\n|---|\n| b |\n\n",
7953            "Last paragraph.\n",
7954        );
7955        let line_count = md.lines().count() as u32;
7956        let html = render_review(md, true).await;
7957        let emitted = emitted_block_lines(&html);
7958        assert!(!emitted.is_empty(), "{html}");
7959        for line in emitted {
7960            assert!(
7961                (1..=line_count).contains(&line),
7962                "line {line} outside 1..={line_count}: {html}"
7963            );
7964        }
7965    }
7966
7967    // --- ```chat blocks ---
7968
7969    /// The part of `html` from the chat block to the `## After` heading.
7970    fn chat_part(html: &str) -> &str {
7971        let start = html
7972            .find("<div class=\"mbr-chat\"")
7973            .unwrap_or_else(|| panic!("no chat block in: {html}"));
7974        let end = html
7975            .find("After</h2>")
7976            .and_then(|at| html[..at].rfind("<h2"))
7977            .unwrap_or(html.len());
7978        &html[start..end]
7979    }
7980
7981    const CHAT_PAGE: &str = concat!(
7982        "# Page\n",                                              // 1
7983        "\n",                                                    // 2
7984        "```chat\n",                                             // 3
7985        "> Bob\n",                                               // 4
7986        "Between [bubbles](other.md).\n",                        // 5
7987        "{{Alice|See [rel](other.md) and [[Other]]|5:42 PM}}\n", // 6
7988        "{{Bob|first\n",                                         // 7
7989        "\n",                                                    // 8
7990        "## Body heading\n",                                     // 9
7991        "\n",                                                    // 10
7992        "---\n",                                                 // 11
7993        "\n",                                                    // 12
7994        "TODO: later\n",                                         // 13
7995        "|}}\n",                                                 // 14
7996        "...\n",                                                 // 15
7997        "# A note with [a link](other.md)\n",                    // 16
7998        "```\n",                                                 // 17
7999        "\n",                                                    // 18
8000        "## After\n",                                            // 19
8001    );
8002
8003    #[tokio::test]
8004    async fn chat_block_renders_bubbles_with_page_link_transform() {
8005        let result = render_with_wikilinks(CHAT_PAGE, "/sub/page/", None, None).await;
8006        let chat = chat_part(&result.html);
8007
8008        assert!(chat.contains("role=\"log\""), "{chat}");
8009        assert!(
8010            chat.contains("mbr-chat-msg mbr-chat-left mbr-chat-c"),
8011            "Alice is on the left: {chat}"
8012        );
8013        assert!(
8014            chat.contains("mbr-chat-msg mbr-chat-right mbr-chat-c"),
8015            "Bob is on the right: {chat}"
8016        );
8017        assert!(chat.contains("<div class=\"mbr-chat-name\">Alice</div>"));
8018        assert!(chat.contains("<div class=\"mbr-chat-meta\">5:42 PM</div>"));
8019        assert!(chat.contains("mbr-chat-delim"));
8020        assert!(chat.contains("<div class=\"mbr-chat-comment\">"));
8021        assert!(chat.contains("<div class=\"mbr-chat-md\">"));
8022        // Every relative link — in a body, in a comment and between bubbles —
8023        // gets the same trailing-slash transform as the rest of the page.
8024        assert_eq!(
8025            chat.matches("href=\"../other/\"").count(),
8026            3,
8027            "three relative links transformed: {chat}"
8028        );
8029        // A wikilink in a body resolves exactly as one outside the block does.
8030        let outside = render_with_wikilinks("[[Other]]", "/sub/page/", None, None)
8031            .await
8032            .html;
8033        let href_at = outside.find("href=\"").expect("a link") + "href=\"".len();
8034        let href = &outside[href_at..href_at + outside[href_at..].find('"').expect("closed")];
8035        assert!(
8036            chat.contains(&format!("href=\"{href}\">Other</a>")),
8037            "expected {href}: {chat}"
8038        );
8039        // Not a code block any more.
8040        assert!(!chat.contains("<pre"), "{chat}");
8041        assert!(!chat.contains("{{"), "{chat}");
8042    }
8043
8044    #[tokio::test]
8045    async fn chat_body_headings_stay_out_of_the_toc_and_rules_do_not_split_sections() {
8046        let result = render_with_wikilinks(CHAT_PAGE, "/sub/page/", None, None).await;
8047        let ids: Vec<&str> = result.headings.iter().map(|h| h.id.as_str()).collect();
8048        assert_eq!(ids, ["page", "after"], "body heading excluded from TOC");
8049
8050        let chat = chat_part(&result.html);
8051        assert!(chat.contains("<h2>Body heading</h2>"), "{chat}");
8052        assert!(chat.contains("<hr />"), "{chat}");
8053        assert!(
8054            !chat.contains("<section") && !chat.contains("</section>"),
8055            "a body rule must not split sections inside the bubble: {chat}"
8056        );
8057    }
8058
8059    #[tokio::test]
8060    async fn chat_body_rule_does_not_shift_section_attrs() {
8061        let md = "```chat\n{{A|x\n\n---\n\ny|}}\n```\n\n--- {#after .tail}\n\nTail\n";
8062        let html = render_markdown(md).await;
8063        assert!(
8064            html.contains("<section id=\"after\" class=\"tail\">"),
8065            "the attrs of the first real rule land on its section: {html}"
8066        );
8067    }
8068
8069    #[tokio::test]
8070    async fn chat_bodies_carry_their_real_source_lines() {
8071        let html = render_review(CHAT_PAGE, true).await;
8072        let chat = chat_part(&html);
8073        assert_eq!(block_line_of_tag(chat, "<div class=\"mbr-chat\""), Some(3));
8074        assert_eq!(block_line_of(chat, "Between "), Some(5));
8075        assert_eq!(block_line_of(chat, "See "), Some(6));
8076        assert_eq!(block_line_of_tag(chat, "<h2"), Some(9));
8077        assert_eq!(block_line_of(chat, "A note with"), Some(16));
8078        assert!(
8079            chat.contains("id=\"mbr-marker-13\""),
8080            "the TODO in the body anchors to its own line: {chat}"
8081        );
8082        // And nothing after the block is thrown off.
8083        assert_eq!(block_line_of(&html, ">After<"), Some(19));
8084    }
8085
8086    #[tokio::test]
8087    async fn chat_block_inside_a_list_item_keeps_lines() {
8088        let md = concat!(
8089            "- item\n",       // 1
8090            "  ```chat\n",    // 2
8091            "  {{A|one|}}\n", // 3
8092            "  {{B|two|}}\n", // 4
8093            "  ```\n",        // 5
8094        );
8095        let html = render_review(md, false).await;
8096        assert_eq!(block_line_of(&html, "two"), Some(4), "{html}");
8097    }
8098
8099    #[tokio::test]
8100    async fn chat_block_links_are_outbound_links() {
8101        let result = render_with_wikilinks(CHAT_PAGE, "/sub/page/", None, None).await;
8102        assert!(
8103            result
8104                .outbound_links
8105                .iter()
8106                .any(|link| link.to.contains("other")),
8107            "{:?}",
8108            result.outbound_links
8109        );
8110    }
8111
8112    #[tokio::test]
8113    async fn non_chat_fences_are_untouched() {
8114        for info in ["chat-old", "rust", ""] {
8115            let md = format!("```{info}\n{{{{A|b|c}}}}\n```\n");
8116            let html = render_markdown(&md).await;
8117            assert!(!html.contains("mbr-chat"), "{info}: {html}");
8118            assert!(html.contains("{{A|b|c}}"), "{info}: {html}");
8119        }
8120    }
8121
8122    #[tokio::test]
8123    async fn chat_header_and_subtext_are_escaped() {
8124        let html = render_markdown("```chat\n{{<img src=x onerror=alert(1)>|hi|<b>}}\n```\n").await;
8125        assert!(!html.contains("<img src=x"), "{html}");
8126        assert!(html.contains("&lt;img src=x"), "{html}");
8127        assert!(html.contains("&lt;b&gt;"), "{html}");
8128    }
8129
8130    // ---- reading through a vault --------------------------------------------
8131
8132    fn mem_vault() -> std::sync::Arc<crate::vault::MemVault> {
8133        std::sync::Arc::new(
8134            crate::vault::MemVault::new()
8135                .with_file(
8136                    "docs/page.md",
8137                    "---\ntitle: Vault Page\n---\n\n# Hello\n\nSee [other](other.md).\n",
8138                )
8139                .with_file("docs/other.md", "# Other\n"),
8140        )
8141    }
8142
8143    fn key(vault: &crate::vault::MemVault, rel: &str) -> PathBuf {
8144        use crate::vault::Vault;
8145        vault.key(&crate::vault::VaultPath::new(rel).unwrap())
8146    }
8147
8148    /// The sync render reads through the vault (no filesystem behind it) and
8149    /// produces the same page the text alone would.
8150    #[test]
8151    fn render_sync_from_vault_matches_render_str() {
8152        let vault = mem_vault();
8153        let file = key(&vault, "docs/page.md");
8154        let root = PathBuf::from(crate::vault::MemVault::DEFAULT_ROOT);
8155        let from_vault = render_sync_from_vault(
8156            vault.as_ref(),
8157            file.clone(),
8158            &root,
8159            0,
8160            LinkTransformConfig::default(),
8161            None,
8162            false,
8163            false,
8164            HashSet::new(),
8165            ReviewLines::Omit,
8166            false,
8167            &[],
8168            None,
8169        )
8170        .expect("render through the vault");
8171        let source = String::from_utf8(vault.contents("docs/page.md").unwrap()).unwrap();
8172        let from_text = render_str(
8173            &source,
8174            &file,
8175            RenderOptions {
8176                root_path: &root,
8177                oembed_timeout_ms: 0,
8178                link_transform_config: LinkTransformConfig::default(),
8179                oembed_cache: None,
8180                server_mode: false,
8181                transcode_enabled: false,
8182                valid_tag_sources: HashSet::new(),
8183                review: ReviewLines::Omit,
8184                mark_incomplete: false,
8185                incomplete_markers: &[],
8186                wikilink_index: None,
8187            },
8188        )
8189        .unwrap();
8190        assert_eq!(from_vault.html, from_text.html);
8191        assert!(from_vault.html.contains("Hello"));
8192    }
8193
8194    #[tokio::test]
8195    async fn render_from_vault_with_cache_reads_through_the_vault() {
8196        let vault = mem_vault();
8197        let root = PathBuf::from(crate::vault::MemVault::DEFAULT_ROOT);
8198        let rendered = render_from_vault_with_cache(
8199            vault.clone(),
8200            key(&vault, "docs/page.md"),
8201            &root,
8202            0,
8203            LinkTransformConfig::default(),
8204            None,
8205            true,
8206            false,
8207            HashSet::new(),
8208            ReviewLines::Omit,
8209            false,
8210            &[],
8211            None,
8212        )
8213        .await
8214        .expect("render");
8215        assert!(rendered.html.contains("Hello"), "{}", rendered.html);
8216        assert_eq!(
8217            rendered.frontmatter.get("title").and_then(|v| v.as_str()),
8218            Some("Vault Page")
8219        );
8220
8221        // A missing file is a `ReadFailed` naming the key, still `NotFound`.
8222        let missing = key(&vault, "docs/missing.md");
8223        let err = render_from_vault_with_cache(
8224            vault.clone(),
8225            missing.clone(),
8226            &root,
8227            0,
8228            LinkTransformConfig::default(),
8229            None,
8230            true,
8231            false,
8232            HashSet::new(),
8233            ReviewLines::Omit,
8234            false,
8235            &[],
8236            None,
8237        )
8238        .await
8239        .expect_err("missing file");
8240        match err {
8241            MarkdownError::ReadFailed { path, source } => {
8242                assert_eq!(path, missing);
8243                assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
8244            }
8245            other => panic!("unexpected {other:?}"),
8246        }
8247    }
8248
8249    #[test]
8250    fn extract_outbound_links_from_vault_matches_the_text() {
8251        let vault = mem_vault();
8252        let file = key(&vault, "docs/page.md");
8253        let root = PathBuf::from(crate::vault::MemVault::DEFAULT_ROOT);
8254        let links = extract_outbound_links_from_vault(
8255            vault.as_ref(),
8256            &file,
8257            &root,
8258            LinkTransformConfig::default(),
8259            true,
8260            HashSet::new(),
8261            None,
8262        )
8263        .expect("links");
8264        let targets: Vec<&str> = links.iter().map(|l| l.to.as_str()).collect();
8265        assert_eq!(targets, vec!["other.md"]);
8266        // A key outside the vault is refused, not read from disk.
8267        assert!(
8268            read_source(vault.as_ref(), Path::new("/etc/hosts")).is_err(),
8269            "keys outside the vault must not be readable"
8270        );
8271    }
8272}
8273
8274#[cfg(test)]
8275mod proptests {
8276    use super::*;
8277    use proptest::prelude::*;
8278
8279    proptest! {
8280        /// Anchor ids must be unique for any sequence of heading texts —
8281        /// duplicates silently break in-page TOC links and permalinks.
8282        #[test]
8283        fn anchor_ids_are_always_unique(
8284            headings in proptest::collection::vec(any::<String>(), 0..30)
8285        ) {
8286            let mut anchor_ids = HashMap::new();
8287            let ids: Vec<String> = headings
8288                .iter()
8289                .map(|h| generate_anchor_id(h, &mut anchor_ids))
8290                .collect();
8291            let unique: HashSet<&String> = ids.iter().collect();
8292            prop_assert_eq!(unique.len(), ids.len());
8293        }
8294    }
8295
8296    // ---- incomplete-marker invariants ----------------------------------------
8297
8298    const PROP_MARKERS: &[&str] = &["TK", "TODO", "FIXME", "XXX"];
8299
8300    /// One markdown line, drawn from the constructs the marker pass has to cope
8301    /// with: block-initial and mid-line markers, tables (several cells per
8302    /// source line), code fences, images, inline code, tasks, hints and the
8303    /// `--- {attrs}` rewrite.
8304    fn line_fragment() -> impl Strategy<Value = &'static str> {
8305        prop_oneof![
8306            Just("TK"),
8307            Just("TODO: fix this up"),
8308            Just("plain prose with nothing in it"),
8309            Just("some TK buried in the middle of a line"),
8310            Just("two TK on one TK line"),
8311            Just("- [ ] TK a task"),
8312            Just("- [-] TODO abandoned"),
8313            Just("# TODO heading"),
8314            Just("> TK quoted"),
8315            Just("| TK a | TK b |"),
8316            Just("|---|---|"),
8317            Just("```"),
8318            Just("![TK](x.png) and TK"),
8319            Just("`TK` in code and TK outside"),
8320            Just("[TK](https://example.com/TODO) trailing XXX"),
8321            Just("--- {#sec}"),
8322            Just("!> TK hint"),
8323            Just("FIXME -- with \"smart\" punctuation"),
8324            Just(""),
8325        ]
8326    }
8327
8328    fn document() -> impl Strategy<Value = String> {
8329        proptest::collection::vec(line_fragment(), 0..25).prop_map(|lines| {
8330            let mut doc = lines.join("\n");
8331            doc.push('\n');
8332            doc
8333        })
8334    }
8335
8336    /// Runs passes 1 and 3 over `md`, returning the events before and after the
8337    /// marker pass. Pass 2 is skipped: it is a strict 1:1 map (pinned by
8338    /// `process_all_events_maps_one_event_to_one_event`), so it cannot change
8339    /// which slot a text run occupies.
8340    fn marked_events(md: &str) -> (Vec<Event<'_>>, Vec<Event<'_>>) {
8341        let owned: Vec<String> = PROP_MARKERS.iter().map(|m| m.to_string()).collect();
8342        let rule = MarkerRule::new(&owned).expect("a non-empty marker list compiles");
8343        let mut text_lines = TextLines::recording();
8344        let (events, _headings, _attrs) = collect_events_and_headings(
8345            md,
8346            TaskMarkup::Render,
8347            &mut text_lines,
8348            &mut BlockLines::disabled(),
8349        );
8350        let marked = mark_incomplete_blocks(
8351            events.clone(),
8352            &rule,
8353            &text_lines,
8354            &mut BlockLines::disabled(),
8355        );
8356        (events, marked)
8357    }
8358
8359    fn incomplete_opens(events: &[Event<'_>]) -> usize {
8360        events
8361            .iter()
8362            .filter(|event| {
8363                matches!(event, Event::Html(html) if html.starts_with("<span class=\"mbr-incomplete\""))
8364            })
8365            .count()
8366    }
8367
8368    fn span_closes(events: &[Event<'_>]) -> usize {
8369        events
8370            .iter()
8371            .filter(|event| matches!(event, Event::Html(html) if html.as_ref() == INCOMPLETE_SPAN_CLOSE))
8372            .count()
8373    }
8374
8375    proptest! {
8376        /// Every highlight the pass opens must be closed, whatever the document
8377        /// does. An unbalanced span leaks the wash over the rest of the page.
8378        #[test]
8379        fn marker_spans_are_always_balanced(md in document()) {
8380            let (before, after) = marked_events(&md);
8381            let opened = incomplete_opens(&after);
8382            // Pass 1 emits `</span>` of its own for task text, so only the
8383            // *growth* in closes belongs to this pass.
8384            let closed = span_closes(&after) - span_closes(&before);
8385            prop_assert_eq!(opened, closed);
8386        }
8387
8388        /// Duplicate ids are invalid HTML and make `getElementById` pick an
8389        /// arbitrary element, so every `mbr-` anchor in a document must be
8390        /// unique — marker anchors and task anchors share one namespace and are
8391        /// both deep-link targets.
8392        ///
8393        /// Author-supplied ids are deliberately out of scope: two `--- {#sec}`
8394        /// rules really do emit two `<section id="sec">`, which is a
8395        /// pre-existing property of section attributes and no business of this
8396        /// pass.
8397        #[test]
8398        fn no_duplicate_ids(md in document()) {
8399            let owned: Vec<String> = PROP_MARKERS.iter().map(|m| m.to_string()).collect();
8400            let rule = MarkerRule::new(&owned).expect("a non-empty marker list compiles");
8401            let mut text_lines = TextLines::recording();
8402            let (events, _headings, section_attrs) =
8403                collect_events_and_headings(
8404                &md,
8405                TaskMarkup::Render,
8406                &mut text_lines,
8407                &mut BlockLines::disabled(),
8408            );
8409            let marked = mark_incomplete_blocks(events, &rule, &text_lines, &mut BlockLines::disabled());
8410            let mut html = String::new();
8411            crate::html::push_html_mbr_with_attrs(&mut html, marked.into_iter(), section_attrs);
8412
8413            const ATTR: &str = "id=\"mbr-";
8414            let ids: Vec<&str> = html
8415                .match_indices(ATTR)
8416                .map(|(at, _)| {
8417                    let rest = &html[at + ATTR.len()..];
8418                    &rest[..rest.find('"').expect("unterminated id")]
8419                })
8420                .collect();
8421            let unique: HashSet<&&str> = ids.iter().collect();
8422            prop_assert_eq!(unique.len(), ids.len(), "duplicate id in: {}", html);
8423        }
8424    }
8425}