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
27pub(crate) fn markdown_options() -> Options {
41 Options::all()
42}
43
44const BOM: char = '\u{feff}';
51
52pub(crate) fn strip_bom(input: &str) -> &str {
54 input.strip_prefix(BOM).unwrap_or(input)
55}
56
57fn strip_bom_in_place(input: &mut String) {
62 if strip_bom(input).len() != input.len() {
63 input.drain(..BOM.len_utf8());
64 }
65}
66
67fn 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#[derive(Debug, Clone)]
85pub struct ParsedDocument {
86 pub source: String,
88 pub frontmatter: SimpleMetadata,
90 pub headings: Vec<HeadingInfo>,
92 pub has_h1: bool,
94 pub word_count: usize,
96}
97
98impl ParsedDocument {
99 pub fn events(&self) -> TextMergeStream<'_, MDParser<'_>> {
104 let parser = MDParser::new_ext(&self.source, markdown_options());
105 TextMergeStream::new(parser)
106 }
107}
108
109pub 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 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 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#[derive(Debug, Clone, serde::Serialize)]
185pub struct HeadingInfo {
186 pub level: u8,
187 pub text: String,
188 pub id: String,
189}
190
191#[derive(Debug, Clone)]
195pub struct MarkdownRenderResult {
196 pub frontmatter: SimpleMetadata,
198 pub frontmatter_error: Option<String>,
205 pub headings: Vec<HeadingInfo>,
207 pub html: String,
209 pub outbound_links: Vec<OutboundLink>,
211 pub has_h1: bool,
213 pub word_count: usize,
215 pub sentence_count: usize,
221 pub syllable_count: usize,
226 pub ambiguous_wikilinks: Vec<crate::wikilink_index::AmbiguousWikilink>,
233 pub contact: Option<crate::contact::Contact>,
240}
241
242struct EventState {
243 #[allow(dead_code)] root_path: PathBuf,
245 file_path: PathBuf,
251 current_media: Option<MediaEmbed>,
253 in_metadata: bool,
254 in_link: bool, metadata_source: Option<MetadataBlockKind>,
256 metadata_parsed: Option<Yaml>,
257 link_transform_config: LinkTransformConfig,
259 wikilink_index: Option<Arc<WikilinkIndex>>,
263 prefetched_oembed: HashMap<String, PageInfo>,
265 server_mode: bool,
267 transcode_enabled: bool,
269 collected_links: Vec<OutboundLink>,
271 current_link_dest: Option<String>,
273 current_link_text: String,
275 valid_tag_sources: HashSet<String>,
277 word_count: usize,
279 in_code_block: bool,
281 sentence_count: usize,
283 syllable_count: usize,
285 block_needs_sentence_bump: bool,
289 frontmatter_error: Option<String>,
294 ambiguous_wikilinks: Vec<crate::wikilink_index::AmbiguousWikilink>,
298}
299
300pub type SimpleMetadata = BTreeMap<String, serde_json::Value>;
310
311fn 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 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
355pub fn extract_first_h1(markdown_input: &str) -> Option<String> {
360 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 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
393const EM_DASH: &str = "\u{2014}";
395
396pub(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
411fn detect_hint_prefix(text: &str) -> Option<(BlockQuoteKind, &str)> {
417 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#[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 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 && 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 result.push(Event::Rule);
466 section_index += 1;
467 section_attrs.insert(section_index, attrs);
468 i += 3; continue;
470 }
471
472 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
484pub(crate) struct LineIndex {
491 newlines: Vec<usize>,
493}
494
495const ASSUMED_LINE_BYTES: usize = 32;
498
499const MAX_RESERVED_LINES: usize = 1 << 16;
502
503impl LineIndex {
504 pub(crate) fn build(source: &str) -> Self {
505 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 pub(crate) fn line_of(&self, offset: usize) -> u32 {
518 let preceding = self.newlines.partition_point(|&newline| newline < offset);
521 u32::try_from(preceding + 1).unwrap_or(u32::MAX)
522 }
523}
524
525struct TextLine {
528 at: u32,
530 line: u32,
532}
533
534struct TextLines {
562 entries: Vec<TextLine>,
564 enabled: bool,
569}
570
571impl TextLines {
572 fn recording() -> Self {
574 Self {
575 entries: Vec::new(),
576 enabled: true,
577 }
578 }
579
580 fn disabled() -> Self {
583 Self {
584 entries: Vec::new(),
585 enabled: false,
586 }
587 }
588
589 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 fn truncate_to(&mut self, len: usize) {
610 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
625struct TextLineCursor<'a> {
631 entries: &'a [TextLine],
632 next: usize,
633}
634
635impl TextLineCursor<'_> {
636 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
671pub enum ReviewLines {
672 Emit,
674 TermsOnly,
679 Omit,
681}
682
683impl ReviewLines {
684 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
696fn 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
737fn is_definition_term_start(event: &Event<'_>) -> bool {
740 matches!(event, Event::Start(Tag::DefinitionListTitle))
741}
742
743struct BlockLines {
776 entries: Vec<crate::html::BlockLine>,
778 enabled: bool,
782 terms_only: bool,
784}
785
786impl BlockLines {
787 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 #[cfg(test)]
798 fn recording() -> Self {
799 Self::for_mode(ReviewLines::Emit)
800 }
801
802 fn disabled() -> Self {
805 Self::for_mode(ReviewLines::Omit)
806 }
807
808 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 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 fn numbers_every_block(&self) -> bool {
834 self.enabled && !self.terms_only
835 }
836
837 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 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 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 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 fn into_entries(self) -> Vec<crate::html::BlockLine> {
900 self.entries
901 }
902}
903
904struct BlockLineCursor<'a> {
910 entries: &'a [crate::html::BlockLine],
911 next: usize,
912}
913
914impl BlockLineCursor<'_> {
915 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
934fn 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
960fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1002enum TaskMarkup {
1003 Render,
1004 Skip,
1005}
1006
1007fn task_marker_status(event: &Event<'_>, at_item_start: bool) -> Option<TaskStatus> {
1015 match event {
1016 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
1024pub(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 Some(b' ' | b'\t') => Some((TaskStatus::Canceled, &rest[1..])),
1040 Some(_) => None,
1041 }
1042}
1043
1044fn 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 _ => false,
1083 }
1084}
1085
1086fn 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 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 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 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 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 if let Event::Text(text) = &event
1216 && let Some((_, rest)) = split_extended_marker(text)
1217 {
1218 task.text_at.push(events.len());
1219 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 continue;
1232 }
1233
1234 if let Some(task) = pending_task.as_mut() {
1235 if is_inline_event(&event) {
1236 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 if let Some(task) = pending_task.take() {
1252 close_task(&mut events, task);
1253 }
1254 }
1255 }
1256
1257 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 _ => {}
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 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 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 push_event(&mut events, text_lines, block_lines, event, source_line);
1312 }
1313
1314 Event::Text(text) if matches!(events.last(), Some(Event::Start(Tag::Paragraph))) => {
1318 if let Some((kind, rest)) = detect_hint_prefix(text) {
1319 let block_line = block_lines.line_recorded_at(events.len() - 1);
1328 pop_event(&mut events, text_lines, block_lines); 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 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 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 Event::End(TagEnd::Paragraph) => {
1401 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 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 let parsed = ParsedAttrs::parse(attrs);
1430
1431 pop_event(&mut events, text_lines, block_lines); pop_event(&mut events, text_lines, block_lines); 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 continue;
1452 }
1453 }
1454 push_event(&mut events, text_lines, block_lines, event, source_line);
1455 }
1456
1457 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 if let Some(task) = pending_task.take() {
1471 close_task(&mut events, task);
1472 }
1473
1474 (events, headings, section_attrs)
1475}
1476
1477struct PendingChat {
1479 source: String,
1480 fence_line: Option<u32>,
1483}
1484
1485fn 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 let content_line = chat.fence_line.map(|line| line.saturating_add(1));
1531 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 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
1617fn 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 other => other.into_static(),
1646 };
1647 push_event(events, text_lines, block_lines, event, line);
1648 }
1649}
1650
1651struct PendingTask {
1653 text_at: Vec<usize>,
1659}
1660
1661fn 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 _ => "",
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
1696pub struct RenderOptions<'a> {
1702 pub root_path: &'a Path,
1704 pub oembed_timeout_ms: u64,
1707 pub link_transform_config: LinkTransformConfig,
1708 pub oembed_cache: Option<Arc<OembedCache>>,
1711 pub server_mode: bool,
1713 pub transcode_enabled: bool,
1715 pub valid_tag_sources: HashSet<String>,
1717 pub review: ReviewLines,
1719 pub mark_incomplete: bool,
1721 pub incomplete_markers: &'a [String],
1722 pub wikilink_index: Option<Arc<WikilinkIndex>>,
1723}
1724
1725fn 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
1769fn 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 "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 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
1809pub(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
1836static 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
1846fn 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
1860fn 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
1933struct 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
1947fn 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
1958fn 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 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
2000fn 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 let has_h1 = headings.first().is_some_and(|h| h.level == 1);
2022
2023 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 let section_attrs =
2039 transform_section_background_urls(section_attrs, &state.link_transform_config);
2040
2041 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 finalize_render(
2051 processed_events,
2052 state,
2053 section_attrs,
2054 markdown_input,
2055 headings,
2056 has_h1,
2057 block_lines,
2058 )
2059}
2060
2061pub 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#[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 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#[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
2213pub 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
2228async 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#[derive(Debug, Clone)]
2245pub struct FingerprintedSource {
2246 pub text: String,
2248 pub hash: String,
2252}
2253
2254pub 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
2277pub 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 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#[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
2383fn 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
2408fn 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
2422fn 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 let found: Vec<std::ops::Range<usize>> = rule
2438 .find_iter(&text)
2439 .filter(|range| range.start >= skip_before)
2440 .collect();
2441
2442 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
2464fn 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 let mut block_cursor = block_lines.cursor();
2547 let mut remapped = block_lines.empty_like();
2548 let mut code_depth: usize = 0;
2551 let mut image_depth: usize = 0;
2552 let mut highest_anchored_line: u32 = 0;
2554
2555 for (index, event) in events.into_iter().enumerate() {
2558 if block_lines.enabled
2563 && let Some(line) = block_cursor.line_at(index)
2564 {
2565 remapped.record(output.len(), Some(line));
2566 }
2567
2568 let event = match event {
2571 Event::Text(text) => {
2572 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 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 output.insert(
2591 top.start_idx + 1,
2592 open_incomplete_span(line, &mut highest_anchored_line),
2593 );
2594 if block_lines.enabled {
2598 remapped.shift_from(top.start_idx + 1);
2599 }
2600 top.marker_open = true;
2601 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 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 *block_lines = remapped;
2671
2672 output
2673}
2674
2675fn 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 let mut html_output = String::with_capacity(markdown_input.len() * 2);
2696
2697 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 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#[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#[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
2836pub 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
2876pub 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
2899pub 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 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, 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
2946fn 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
2955fn 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
2969fn 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
3009const OEMBED_FETCH_CONCURRENCY: usize = 8;
3015
3016const MAX_OEMBED_FETCHES_PER_DOC: usize = 100;
3019
3020fn 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
3041async 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 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 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 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 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
3116fn 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
3133fn 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 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 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
3186const FRONTMATTER_MAX_BYTES: usize = 8 * 1024;
3189
3190#[derive(Debug, Clone, Default)]
3196pub struct FileMetadata {
3197 pub metadata: SimpleMetadata,
3199 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 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
3215pub 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
3236pub 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 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 !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
3303pub(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 ' '
3327 }
3328 })
3329 .collect::<String>()
3330 .split_whitespace()
3331 .collect::<Vec<_>>()
3332 .join("-")
3333}
3334
3335fn generate_anchor_id(text: &str, anchor_ids: &mut HashMap<String, usize>) -> String {
3344 let base_id = slugify(text);
3345
3346 let base_id = if base_id.is_empty() {
3348 "heading".to_string()
3349 } else {
3350 base_id
3351 };
3352
3353 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 anchor_ids.entry(candidate.clone()).or_insert(0);
3371 candidate
3372}
3373
3374fn 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 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 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 Event::Start(Tag::Link {
3442 link_type,
3443 dest_url,
3444 title,
3445 id,
3446 }) => {
3447 state.in_link = true;
3448 state.current_link_dest = Some(dest_url.to_string());
3450 state.current_link_text.clear();
3451
3452 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 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 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 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 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 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 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 if state.in_link {
3557 state.current_link_text.push_str(text);
3558 }
3559 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 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 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 (event, state)
3600 } else if !state.in_link && text.starts_with("http") && !text.contains(' ') {
3601 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 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 async fn render_markdown_with_mode(content: &str, server_mode: bool) -> String {
3670 render_str_with_mode(content, server_mode).html
3671 }
3672
3673 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 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 #[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 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 async fn render_markdown_marked(content: &str, markers: &[&str]) -> String {
3824 render_result_marked(content, markers).await.html
3825 }
3826
3827 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 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, 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 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, 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 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 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 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 #[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 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 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 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 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 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 assert!(
4189 result.html.contains(r#"href="/people/john-jr/""#),
4190 "expected the first-wins target, got: {}",
4191 result.html
4192 );
4193 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 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 assert_eq!(count_sentence_terminators("Wait..."), (1, true));
4251 assert_eq!(count_sentence_terminators("v1.2.3 is out."), (1, true));
4253 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 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 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 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 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: & < >"), "{html}");
4354 }
4355
4356 #[tokio::test]
4357 async fn checkboxes_are_inert_in_every_mode() {
4358 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 #[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 #[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 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 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 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 #[tokio::test]
4491 async fn task_line_numbers_survive_intervening_blocks() {
4492 let md = concat!(
4493 "---\n", "title: T\n", "---\n", "\n", "# Heading\n", "\n", "- [ ] first\n", "\n", "```js\n", "// - [ ] fake\n", "```\n", "\n", "Some prose.\n", "\n", "- [x] second\n", "- [-] third\n", );
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 #[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 assert_eq!(incomplete_span_count(&html), 1, "{html}");
4543 assert!(html.contains(r#"id="mbr-task-1""#), "{html}");
4545 assert!(html.contains(r#"id="mbr-marker-1""#), "{html}");
4546 }
4547
4548 #[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 #[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 assert!(!html.contains("/tags/rust/"), "{html}");
4597 }
4598
4599 #[test]
4600 fn line_index_maps_offsets_to_one_based_lines() {
4601 let index = LineIndex::build("ab\nc\n\nd");
4603 assert_eq!(index.line_of(0), 1);
4604 assert_eq!(index.line_of(2), 1); assert_eq!(index.line_of(3), 2);
4606 assert_eq!(index.line_of(5), 3); assert_eq!(index.line_of(6), 4);
4608 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 #[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 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 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 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 #[tokio::test]
4884 async fn test_video_embed_from_image_syntax() {
4885 let md = "";
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 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 = "";
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 = "";
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 = "";
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 = "";
4940 let html = render_markdown(md).await;
4941 assert!(html.contains("pdf-embed"));
4942 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 = "";
4958 let html = render_markdown(md).await;
4959 assert!(html.contains("pdf-embed"));
4960 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 = "";
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
4985
4986
4987
4988
4989
4990
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 #[tokio::test]
5025 async fn test_link_transformation_regular_markdown() {
5026 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 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 let md = "";
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 = "";
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 #[tokio::test]
5094 async fn test_video_embed_url_transformation() {
5095 let md = "";
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 let md = "";
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 let md = "";
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 let md = "";
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 let md = "";
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 let md = "";
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 let md = "";
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 #[tokio::test]
5191 async fn test_section_attrs_with_id() {
5192 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 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 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 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 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 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 let md = "First section\n\n---\n\nSecond section";
5281 let html = render_markdown(md).await;
5282 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 let md = "Some text\n\n--- not attrs\n\nMore text";
5301 let html = render_markdown(md).await;
5302 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 let md = "First section\n\n--- {}\n\nSecond section";
5315 let html = render_markdown(md).await;
5316 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 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 let md = "First section\n\n--- {data-x=\u{201C}value\u{201D}}\n\nSecond section";
5352 let html = render_markdown(md).await;
5353 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 let md = "First section\n\n--- {data-val=\"a & b\"}\n\nSecond section";
5367 let html = render_markdown(md).await;
5368 assert!(
5370 html.contains("&"),
5371 "HTML special chars should be escaped. Got: {}",
5372 html
5373 );
5374 assert!(
5375 html.contains(r#"data-val="a & b""#),
5376 "Value should have escaped &. Got: {}",
5377 html
5378 );
5379 }
5380
5381 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 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&b=2\">x</a>", None);
5444 assert!(
5445 html.contains(r#"href="../page.html?a=1&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&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#"<img src="x.png">"#), "{html}");
5500 assert!(html.contains(r#"<img src="y.png">"#), "{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 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 #[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&y=2 2x\">", None);
5606 assert!(
5607 html.contains(r#"srcset="../a.png?x=1&y=2 2x""#),
5608 "{html}"
5609 );
5610 }
5611
5612 #[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 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 fn image_src(path: &str, url_depth: Option<usize>) -> String {
5675 let html = render_with_depth(&format!(""), 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 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 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 const DEFAULT_MARKERS: &[&str] = &["TK", "TODO", "FIXME", "XXX"];
5881
5882 const INCOMPLETE_SPAN_PREFIX: &str = "<span class=\"mbr-incomplete\"";
5889
5890 fn incomplete_span_count(html: &str) -> usize {
5892 html.matches(INCOMPLETE_SPAN_PREFIX).count()
5893 }
5894
5895 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 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 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 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 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 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("!>"), "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 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("!>"), "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 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 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 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("!> 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 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 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 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 #[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 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 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 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 #[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 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 #[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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[tokio::test]
6417 async fn marker_in_image_alt_text_is_not_highlighted() {
6418 let html = render_markdown_marked(" 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 #[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 #[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 #[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 #[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 #[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 #[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 .\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 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 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 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 let sources = make_sources(&["tags"]);
6658 let md = "See [[category:books]] for more.";
6659 let html = render_markdown_with_tags(md, sources).await;
6660 assert!(
6662 html.contains("<a"),
6663 "Wikilink should become a link via pulldown-cmark. Got: {}",
6664 html
6665 );
6666 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 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 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 let sources = make_sources(&["tags", "https"]); 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 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 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 let sources = HashSet::new();
6758 let md = "See [[Tags:rust]] for more.";
6759 let html = render_markdown_with_tags(md, sources).await;
6760 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 #[tokio::test]
6777 async fn test_plain_wikilink_works() {
6778 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 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 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 assert!(
6816 html.contains(r#"href="/tags/rust/""#),
6817 "Tag wikilink should transform to /tags/rust/. Got: {}",
6818 html
6819 );
6820 assert!(
6822 html.contains("MyPage"),
6823 "Plain wikilink should reference MyPage. Got: {}",
6824 html
6825 );
6826 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 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 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 #[test]
6883 fn yaml_loader_yields_no_documents_for_comment_only_frontmatter() {
6884 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 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 #[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!(
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 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 #[tokio::test]
7079 async fn local_embeds_render_at_timeout_zero_in_both_paths() {
7080 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 #[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 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 #[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 #[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 #[test]
7186 fn slugify_returns_empty_for_nothing_to_keep() {
7187 assert_eq!(slugify(""), "");
7188 assert_eq!(slugify("!!!"), "");
7189 }
7190
7191 #[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 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 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 #[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 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 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 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 fn recorded_block_lines(md: &str) -> Vec<(u32, u32)> {
7316 pass_one(md).1
7317 }
7318
7319 async fn render_review(md: &str, mark_incomplete: bool) -> String {
7322 render_review_with(md, mark_incomplete, ReviewLines::Emit).await
7323 }
7324
7325 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 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 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 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 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 let md = concat!(
7418 "# H\n\n", "Para.\n\n", "- item\n\n", "> quote\n\n", "```\ncode\n```\n\n", "| A |\n|---|\n| b |", );
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 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 assert_eq!(
7443 recorded.len(),
7444 7,
7445 "no record for Tag::List or Tag::TableCell: {recorded:?}"
7446 );
7447 }
7448
7449 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 #[tokio::test]
7464 async fn block_lines_on_definition_terms_and_definitions() {
7465 let md = concat!(
7466 "---\n", "type: flashcard\n", "---\n", "\n", "\n", ": first answer\n", ": second answer\n", "\n", "Loose term\n", "\n", ": loose answer\n", "\n", "Last term\n", ": last answer\n", );
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 assert!(
7486 html.contains(r#"<dt data-mbr-line="9" tabindex="0">"#),
7487 "{html}"
7488 );
7489 }
7490
7491 #[tokio::test]
7499 async fn block_lines_on_definition_lists_inside_chat_bodies() {
7500 let md = concat!(
7501 "Before term\n", ": before answer\n", "\n", "```chat\n", "{{Alice|Chat term\n", ": chat answer\n", "|}}\n", "Between term\n", ": between answer\n", "```\n", "\n", "After term\n", ": after answer\n", );
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 #[test]
7552 fn pop_event_truncates_block_lines() {
7553 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 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 #[tokio::test]
7617 async fn block_lines_survive_pass_three_insertion() {
7618 let md = concat!(
7619 "- ```\n", " code\n", " ```\n", " TODO after\n\n", "Later paragraph\n", );
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 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 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 #[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", "Beta with a FIXME mid-run.\n\n", "# Gamma has an XXX in it\n\n", "Delta is plain.\n", );
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 #[tokio::test]
7680 async fn terms_only_numbers_definition_terms_and_nothing_else() {
7681 let md = concat!(
7682 "# Heading\n\n", "A paragraph.\n\n", "- TODO an item\n\n", "> a quote\n\n", "First term\n", ": TODO first answer\n", "\n", "Loose term\n\n", ": loose answer\n\n", "```chat\n", "{{Alice|Chat term\n", ": chat answer\n", "|}}\n", "```\n\n", "```\ncode\n```\n", );
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 #[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 #[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 #[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 #[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 "\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 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 #[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 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", "\n", "```chat\n", "> Bob\n", "Between [bubbles](other.md).\n", "{{Alice|See [rel](other.md) and [[Other]]|5:42 PM}}\n", "{{Bob|first\n", "\n", "## Body heading\n", "\n", "---\n", "\n", "TODO: later\n", "|}}\n", "...\n", "# A note with [a link](other.md)\n", "```\n", "\n", "## After\n", );
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 assert_eq!(
8025 chat.matches("href=\"../other/\"").count(),
8026 3,
8027 "three relative links transformed: {chat}"
8028 );
8029 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 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 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", " ```chat\n", " {{A|one|}}\n", " {{B|two|}}\n", " ```\n", );
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("<img src=x"), "{html}");
8127 assert!(html.contains("<b>"), "{html}");
8128 }
8129
8130 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 #[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 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 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 #[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 const PROP_MARKERS: &[&str] = &["TK", "TODO", "FIXME", "XXX"];
8299
8300 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(" 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 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 #[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 let closed = span_closes(&after) - span_closes(&before);
8385 prop_assert_eq!(opened, closed);
8386 }
8387
8388 #[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}