use crate::attrs::ParsedAttrs;
use crate::errors::MarkdownError;
use crate::link_index::{OutboundLink, is_internal_link, split_url_anchor};
use crate::link_transform::{LinkTransformConfig, transform_link};
use crate::media::MediaEmbed;
use crate::oembed::PageInfo;
use crate::oembed_cache::OembedCache;
use crate::tasks::{self, MarkerRule, TaskStatus};
use crate::vid::Vid;
use crate::wikilink::{parse_tag_link, transform_wikilinks};
use crate::wikilink_index::WikilinkIndex;
use pulldown_cmark::{
BlockQuoteKind, CowStr, Event, HeadingLevel, LinkType, MetadataBlockKind, Options,
Parser as MDParser, Tag, TagEnd, TextMergeStream, TextMergeWithOffset,
};
use std::{
collections::{BTreeMap, HashMap, HashSet},
fs::{self, File},
io::Read,
path::{Path, PathBuf},
sync::Arc,
};
use yaml_rust2::{Yaml, YamlLoader};
pub(crate) fn markdown_options() -> Options {
Options::all()
}
const BOM: char = '\u{feff}';
fn strip_bom(input: &str) -> &str {
input.strip_prefix(BOM).unwrap_or(input)
}
fn strip_bom_in_place(input: &mut String) {
if strip_bom(input).len() != input.len() {
input.drain(..BOM.len_utf8());
}
}
fn load_first_yaml_doc(text: &str) -> Option<Yaml> {
YamlLoader::load_from_str(text)
.ok()
.and_then(|docs| docs.into_iter().next())
}
#[derive(Debug, Clone)]
pub struct ParsedDocument {
pub source: String,
pub frontmatter: SimpleMetadata,
pub headings: Vec<HeadingInfo>,
pub has_h1: bool,
pub word_count: usize,
}
impl ParsedDocument {
pub fn events(&self) -> TextMergeStream<'_, MDParser<'_>> {
let parser = MDParser::new_ext(&self.source, markdown_options());
TextMergeStream::new(parser)
}
}
pub fn parse<P: AsRef<Path>>(file: P) -> Result<ParsedDocument, MarkdownError> {
let file = file.as_ref();
let mut markdown_input = fs::read_to_string(file).map_err(|e| MarkdownError::ReadFailed {
path: file.to_path_buf(),
source: e,
})?;
strip_bom_in_place(&mut markdown_input);
let (events, headings, _section_attrs) = collect_events_and_headings(
&markdown_input,
TaskMarkup::Skip,
&mut TextLines::disabled(),
);
let has_h1 = headings.first().is_some_and(|h| h.level == 1);
let mut frontmatter = SimpleMetadata::new();
let mut word_count: usize = 0;
let mut in_yaml = false;
let mut in_code_block = false;
let mut in_metadata_block = false;
for event in &events {
match event {
Event::Start(Tag::MetadataBlock(MetadataBlockKind::YamlStyle)) => {
in_yaml = true;
in_metadata_block = true;
}
Event::End(TagEnd::MetadataBlock(MetadataBlockKind::YamlStyle)) => {
in_yaml = false;
in_metadata_block = false;
}
Event::Text(text) if in_yaml => {
let metadata_parsed = load_first_yaml_doc(text);
frontmatter = yaml_frontmatter_simplified(&metadata_parsed);
in_yaml = false;
}
Event::Start(Tag::MetadataBlock(_)) => in_metadata_block = true,
Event::End(TagEnd::MetadataBlock(_)) => in_metadata_block = false,
Event::Start(Tag::CodeBlock(_)) => in_code_block = true,
Event::End(TagEnd::CodeBlock) => in_code_block = false,
Event::Text(text) if !in_code_block && !in_metadata_block => {
word_count += text.split_whitespace().count();
}
_ => {}
}
}
if !frontmatter.contains_key("title") && has_h1 {
frontmatter.insert(
"title".to_string(),
serde_json::Value::String(headings[0].text.clone()),
);
}
Ok(ParsedDocument {
source: markdown_input,
frontmatter,
headings,
has_h1,
word_count,
})
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct HeadingInfo {
pub level: u8,
pub text: String,
pub id: String,
}
#[derive(Debug, Clone)]
pub struct MarkdownRenderResult {
pub frontmatter: SimpleMetadata,
pub frontmatter_error: Option<String>,
pub headings: Vec<HeadingInfo>,
pub html: String,
pub outbound_links: Vec<OutboundLink>,
pub has_h1: bool,
pub word_count: usize,
pub sentence_count: usize,
pub syllable_count: usize,
pub ambiguous_wikilinks: Vec<crate::wikilink_index::AmbiguousWikilink>,
}
struct EventState {
#[allow(dead_code)] root_path: PathBuf,
file_path: PathBuf,
current_media: Option<MediaEmbed>,
in_metadata: bool,
in_link: bool, metadata_source: Option<MetadataBlockKind>,
metadata_parsed: Option<Yaml>,
link_transform_config: LinkTransformConfig,
wikilink_index: Option<Arc<WikilinkIndex>>,
prefetched_oembed: HashMap<String, PageInfo>,
server_mode: bool,
transcode_enabled: bool,
collected_links: Vec<OutboundLink>,
current_link_dest: Option<String>,
current_link_text: String,
valid_tag_sources: HashSet<String>,
word_count: usize,
in_code_block: bool,
sentence_count: usize,
syllable_count: usize,
block_needs_sentence_bump: bool,
frontmatter_error: Option<String>,
ambiguous_wikilinks: Vec<crate::wikilink_index::AmbiguousWikilink>,
}
pub type SimpleMetadata = BTreeMap<String, serde_json::Value>;
fn count_sentence_terminators(text: &str) -> (usize, bool) {
let bytes = text.as_bytes();
let mut count: usize = 0;
let mut prev_was_terminator = false;
for (i, &b) in bytes.iter().enumerate() {
let is_terminator = matches!(b, b'.' | b'!' | b'?');
if is_terminator && !prev_was_terminator {
let next_is_boundary = bytes[i + 1..]
.iter()
.find(|&&c| !matches!(c, b'.' | b'!' | b'?'))
.is_none_or(|&c| c.is_ascii_whitespace());
if next_is_boundary {
count += 1;
}
}
prev_was_terminator = is_terminator;
}
let ends_with_terminator = text
.trim_end()
.chars()
.next_back()
.is_some_and(|c| matches!(c, '.' | '!' | '?'));
(count, ends_with_terminator)
}
pub fn extract_first_h1(markdown_input: &str) -> Option<String> {
let parser = MDParser::new_ext(markdown_input, Options::ENABLE_YAML_STYLE_METADATA_BLOCKS);
let parser = TextMergeStream::new(parser);
let mut in_h1 = false;
let mut h1_text = String::new();
for event in parser {
match event {
Event::Start(Tag::Heading {
level: HeadingLevel::H1,
..
}) => {
in_h1 = true;
}
Event::Text(text) | Event::Code(text) if in_h1 => {
h1_text.push_str(&text);
}
Event::End(TagEnd::Heading(HeadingLevel::H1)) => {
if !h1_text.is_empty() {
return Some(h1_text);
}
in_h1 = false;
}
_ => {}
}
}
None
}
const EM_DASH: &str = "\u{2014}";
fn detect_hint_prefix(text: &str) -> Option<(BlockQuoteKind, &str)> {
let (prefix, kind) = match text.as_bytes().first()? {
b'!' => ("!> ", BlockQuoteKind::Tip),
b'?' => ("?> ", BlockQuoteKind::Warning),
b'x' => ("x> ", BlockQuoteKind::Caution),
_ => return None,
};
text.strip_prefix(prefix).map(|rest| (kind, rest))
}
#[allow(dead_code)]
fn transform_rule_attrs(events: Vec<Event<'_>>) -> (Vec<Event<'_>>, HashMap<usize, ParsedAttrs>) {
let mut result = Vec::with_capacity(events.len());
let mut section_attrs = HashMap::new();
let mut section_index = 0;
let mut i = 0;
while i < events.len() {
if i + 2 < events.len()
&& let (Event::Start(Tag::Paragraph), Event::Text(text), Event::End(TagEnd::Paragraph)) =
(&events[i], &events[i + 1], &events[i + 2])
&& text.starts_with(EM_DASH)
&& let Some(attrs_str) = text.strip_prefix(EM_DASH)
&& attrs_str.starts_with(" {")
&& attrs_str.ends_with('}')
&& let Some(attrs) = ParsedAttrs::parse(attrs_str.trim())
{
result.push(Event::Rule);
section_index += 1;
section_attrs.insert(section_index, attrs);
i += 3; continue;
}
if matches!(&events[i], Event::Rule) {
section_index += 1;
}
result.push(events[i].clone());
i += 1;
}
(result, section_attrs)
}
struct LineIndex {
newlines: Vec<usize>,
}
const ASSUMED_LINE_BYTES: usize = 32;
const MAX_RESERVED_LINES: usize = 1 << 16;
impl LineIndex {
fn build(source: &str) -> Self {
let mut newlines =
Vec::with_capacity((source.len() / ASSUMED_LINE_BYTES).min(MAX_RESERVED_LINES));
newlines.extend(source.match_indices('\n').map(|(offset, _)| offset));
Self { newlines }
}
fn line_of(&self, offset: usize) -> u32 {
let preceding = self.newlines.partition_point(|&newline| newline < offset);
u32::try_from(preceding + 1).unwrap_or(u32::MAX)
}
}
struct TextLine {
at: u32,
line: u32,
}
struct TextLines {
entries: Vec<TextLine>,
enabled: bool,
}
impl TextLines {
fn recording() -> Self {
Self {
entries: Vec::new(),
enabled: true,
}
}
fn disabled() -> Self {
Self {
entries: Vec::new(),
enabled: false,
}
}
fn record(&mut self, at: usize, line: Option<u32>) {
if !self.enabled {
return;
}
let Some(line) = line else {
return;
};
let at = u32::try_from(at).unwrap_or(u32::MAX);
debug_assert!(
self.entries.last().is_none_or(|last| last.at < at),
"text-line records must be strictly ascending; the monotone cursor \
that reads them back cannot recover from a repeat or a rewind"
);
self.entries.push(TextLine { at, line });
}
fn truncate_to(&mut self, len: usize) {
let keep = self
.entries
.partition_point(|entry| (entry.at as usize) < len);
self.entries.truncate(keep);
}
fn cursor(&self) -> TextLineCursor<'_> {
TextLineCursor {
entries: &self.entries,
next: 0,
}
}
}
struct TextLineCursor<'a> {
entries: &'a [TextLine],
next: usize,
}
impl TextLineCursor<'_> {
fn line_at(&mut self, index: usize) -> Option<u32> {
while self
.entries
.get(self.next)
.is_some_and(|entry| (entry.at as usize) < index)
{
self.next += 1;
}
self.entries
.get(self.next)
.filter(|entry| entry.at as usize == index)
.map(|entry| entry.line)
}
}
fn push_event<'a>(
events: &mut Vec<Event<'a>>,
text_lines: &mut TextLines,
event: Event<'a>,
line: Option<u32>,
) {
if matches!(event, Event::Text(_)) {
text_lines.record(events.len(), line);
}
events.push(event);
}
fn pop_event<'a>(events: &mut Vec<Event<'a>>, text_lines: &mut TextLines) -> Option<Event<'a>> {
let popped = events.pop();
text_lines.truncate_to(events.len());
popped
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TaskMarkup {
Render,
Skip,
}
fn task_marker_status(event: &Event<'_>, at_item_start: bool) -> Option<TaskStatus> {
match event {
Event::TaskListMarker(true) => Some(TaskStatus::Done),
Event::TaskListMarker(false) => Some(TaskStatus::Open),
Event::Text(text) if at_item_start => split_extended_marker(text).map(|(status, _)| status),
_ => None,
}
}
fn split_extended_marker(text: &str) -> Option<(TaskStatus, &str)> {
let rest = text
.strip_prefix("[-]")
.or_else(|| text.strip_prefix("[>]"))?;
match rest.as_bytes().first() {
None => Some((TaskStatus::Canceled, rest)),
Some(b' ' | b'\t') => Some((TaskStatus::Canceled, &rest[1..])),
Some(_) => None,
}
}
fn is_inline_event(event: &Event<'_>) -> bool {
match event {
Event::Text(_)
| Event::Code(_)
| Event::InlineMath(_)
| Event::DisplayMath(_)
| Event::InlineHtml(_)
| Event::FootnoteReference(_)
| Event::SoftBreak
| Event::HardBreak
| Event::TaskListMarker(_) => true,
Event::Start(tag) => matches!(
tag,
Tag::Emphasis
| Tag::Strong
| Tag::Strikethrough
| Tag::Superscript
| Tag::Subscript
| Tag::Link { .. }
| Tag::Image { .. }
),
Event::End(tag) => matches!(
tag,
TagEnd::Emphasis
| TagEnd::Strong
| TagEnd::Strikethrough
| TagEnd::Superscript
| TagEnd::Subscript
| TagEnd::Link
| TagEnd::Image
),
_ => false,
}
}
fn collect_events_and_headings<'a>(
markdown_input: &'a str,
task_markup: TaskMarkup,
text_lines: &mut TextLines,
) -> (
Vec<Event<'a>>,
Vec<HeadingInfo>,
HashMap<usize, ParsedAttrs>,
) {
let parser = MDParser::new_ext(markdown_input, markdown_options()).into_offset_iter();
let parser = TextMergeWithOffset::new(parser);
let mut events = Vec::new();
let mut headings = Vec::new();
let mut anchor_ids: HashMap<String, usize> = HashMap::new();
let mut in_heading_text: Option<String> = None;
let mut section_attrs = HashMap::new();
let mut section_index = 0;
let mut hint_open = false;
let mut line_index: Option<LineIndex> = None;
let mut at_item_start = false;
let mut pending_task: Option<PendingTask> = None;
for (event, range) in parser {
let text_line = if text_lines.enabled && matches!(event, Event::Text(_)) {
let index = line_index.get_or_insert_with(|| LineIndex::build(markdown_input));
Some(index.line_of(range.start))
} else {
None
};
let was_at_item_start = at_item_start;
at_item_start = matches!(event, Event::Start(Tag::Item))
|| (at_item_start && matches!(event, Event::Start(Tag::Paragraph)));
if task_markup == TaskMarkup::Render {
if let Some(status) = task_marker_status(&event, was_at_item_start) {
if let Some(open) = pending_task.take() {
close_task(&mut events, open);
}
let index = line_index.get_or_insert_with(|| LineIndex::build(markdown_input));
let line = index.line_of(range.start);
push_event(
&mut events,
text_lines,
Event::Html(CowStr::from(crate::html::task_checkbox_html(
status,
Some(line),
))),
text_line,
);
push_event(
&mut events,
text_lines,
Event::Html(CowStr::from(crate::html::task_text_open(status))),
text_line,
);
let mut task = PendingTask {
text_at: Vec::new(),
};
if let Event::Text(text) = &event
&& let Some((_, rest)) = split_extended_marker(text)
{
task.text_at.push(events.len());
push_event(
&mut events,
text_lines,
Event::Text(CowStr::from(rest.to_string())),
text_line,
);
}
pending_task = Some(task);
continue;
}
if let Some(task) = pending_task.as_mut() {
if is_inline_event(&event) {
if matches!(event, Event::Text(_)) {
task.text_at.push(events.len());
}
push_event(&mut events, text_lines, event, text_line);
continue;
}
if let Some(task) = pending_task.take() {
close_task(&mut events, task);
}
}
}
match &event {
Event::Start(Tag::Heading { .. }) => {
in_heading_text = Some(String::new());
push_event(&mut events, text_lines, event, text_line);
}
Event::Text(text) | Event::Code(text) | Event::InlineMath(text)
if in_heading_text.is_some() =>
{
if let Some(ref mut heading_text) = in_heading_text {
heading_text.push_str(text);
}
push_event(&mut events, text_lines, event, text_line);
}
Event::Text(text) if matches!(events.last(), Some(Event::Start(Tag::Paragraph))) => {
if let Some((kind, rest)) = detect_hint_prefix(text) {
pop_event(&mut events, text_lines); push_event(
&mut events,
text_lines,
Event::Start(Tag::BlockQuote(Some(kind))),
text_line,
);
push_event(
&mut events,
text_lines,
Event::Start(Tag::Paragraph),
text_line,
);
push_event(
&mut events,
text_lines,
Event::Text(CowStr::from(rest.to_owned())),
text_line,
);
hint_open = true;
continue;
}
push_event(&mut events, text_lines, event, text_line);
}
Event::End(TagEnd::Heading(heading_level)) => {
if let Some(text) = in_heading_text.take() {
let id = generate_anchor_id(&text, &mut anchor_ids);
let level_num = match heading_level {
HeadingLevel::H1 => 1,
HeadingLevel::H2 => 2,
HeadingLevel::H3 => 3,
HeadingLevel::H4 => 4,
HeadingLevel::H5 => 5,
HeadingLevel::H6 => 6,
};
headings.push(HeadingInfo {
level: level_num,
text: text.clone(),
id: id.clone(),
});
for i in (0..events.len()).rev() {
if let Event::Start(Tag::Heading {
level,
id: _,
classes,
attrs,
}) = &events[i]
{
events[i] = Event::Start(Tag::Heading {
level: *level,
id: Some(CowStr::from(id)),
classes: classes.clone(),
attrs: attrs.clone(),
});
break;
}
}
}
push_event(&mut events, text_lines, event, text_line);
}
Event::End(TagEnd::Paragraph) => {
if hint_open {
push_event(&mut events, text_lines, event, text_line);
push_event(
&mut events,
text_lines,
Event::End(TagEnd::BlockQuote(None)),
text_line,
);
hint_open = false;
continue;
}
let len = events.len();
if len >= 2 {
let is_rule_attrs = matches!(
(&events[len - 2], &events[len - 1]),
(Event::Start(Tag::Paragraph), Event::Text(_))
) && {
if let Event::Text(text) = &events[len - 1] {
text.starts_with(EM_DASH)
&& text.strip_prefix(EM_DASH).is_some_and(|rest| {
rest.starts_with(" {") && rest.ends_with('}')
})
} else {
false
}
};
if is_rule_attrs {
let parsed = if let Event::Text(text) = &events[len - 1] {
text.strip_prefix(EM_DASH)
.and_then(|rest| ParsedAttrs::parse(rest.trim()))
} else {
None
};
pop_event(&mut events, text_lines); pop_event(&mut events, text_lines);
push_event(&mut events, text_lines, Event::Rule, text_line);
section_index += 1;
if let Some(attrs) = parsed {
section_attrs.insert(section_index, attrs);
}
continue;
}
}
push_event(&mut events, text_lines, event, text_line);
}
Event::Rule => {
section_index += 1;
push_event(&mut events, text_lines, event, text_line);
}
_ => {
push_event(&mut events, text_lines, event, text_line);
}
}
}
if let Some(task) = pending_task.take() {
close_task(&mut events, task);
}
(events, headings, section_attrs)
}
struct PendingTask {
text_at: Vec<usize>,
}
fn close_task(output: &mut Vec<Event<'_>>, task: PendingTask) {
let (stripped, annotations) = {
let runs: Vec<&str> = task
.text_at
.iter()
.map(|&index| match &output[index] {
Event::Text(text) => text.as_ref(),
_ => "",
})
.collect();
tasks::strip_annotations_across_runs(&runs)
};
for (&index, text) in task.text_at.iter().zip(stripped) {
output[index] = Event::Text(CowStr::from(text));
}
output.push(Event::Html(CowStr::from(crate::html::TASK_TEXT_CLOSE)));
let chips = crate::html::task_annotations_html(&annotations);
if !chips.is_empty() {
output.push(Event::Html(CowStr::from(chips)));
}
}
#[allow(clippy::too_many_arguments)]
pub async fn render(
file: PathBuf,
root_path: &Path,
oembed_timeout_ms: u64,
link_transform_config: LinkTransformConfig,
server_mode: bool,
transcode_enabled: bool,
valid_tag_sources: HashSet<String>,
mark_incomplete: bool,
incomplete_markers: &[String],
wikilink_index: Option<Arc<WikilinkIndex>>,
) -> Result<MarkdownRenderResult, MarkdownError> {
render_with_cache(
file,
root_path,
oembed_timeout_ms,
link_transform_config,
None,
server_mode,
transcode_enabled,
valid_tag_sources,
mark_incomplete,
incomplete_markers,
wikilink_index,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub async fn render_with_cache(
file: PathBuf,
root_path: &Path,
oembed_timeout_ms: u64,
link_transform_config: LinkTransformConfig,
oembed_cache: Option<Arc<OembedCache>>,
server_mode: bool,
transcode_enabled: bool,
valid_tag_sources: HashSet<String>,
mark_incomplete: bool,
incomplete_markers: &[String],
wikilink_index: Option<Arc<WikilinkIndex>>,
) -> Result<MarkdownRenderResult, MarkdownError> {
let mut raw_markdown_input =
tokio::fs::read_to_string(&file)
.await
.map_err(|e| MarkdownError::ReadFailed {
path: file.clone(),
source: e,
})?;
strip_bom_in_place(&mut raw_markdown_input);
let markdown_input = if valid_tag_sources.is_empty() {
raw_markdown_input
} else {
transform_wikilinks(&raw_markdown_input, &valid_tag_sources)
};
let marker_rule = mark_incomplete
.then(|| MarkerRule::cached(incomplete_markers))
.flatten();
let mut text_lines = if marker_rule.is_some() {
TextLines::recording()
} else {
TextLines::disabled()
};
let (events_with_ids, headings, section_attrs) =
collect_events_and_headings(&markdown_input, TaskMarkup::Render, &mut text_lines);
let has_h1 = headings.first().is_some_and(|h| h.level == 1);
let mut prefetched_oembed = collect_local_embeds(&events_with_ids);
if oembed_timeout_ms > 0 {
for (url, info) in
prefetch_oembed_urls(&events_with_ids, oembed_timeout_ms, &oembed_cache).await
{
prefetched_oembed.entry(url).or_insert(info);
}
}
let (processed_events, state) = process_all_events(
events_with_ids,
root_path,
&file,
link_transform_config,
prefetched_oembed,
server_mode,
transcode_enabled,
valid_tag_sources,
wikilink_index,
);
let processed_events = match &marker_rule {
Some(rule) => mark_incomplete_blocks(processed_events, rule, &text_lines),
None => processed_events,
};
finalize_render(
processed_events,
state,
section_attrs,
&markdown_input,
headings,
has_h1,
)
}
#[allow(clippy::too_many_arguments)]
fn process_all_events<'a>(
events: Vec<Event<'a>>,
root_path: &Path,
file_path: &Path,
link_transform_config: LinkTransformConfig,
prefetched_oembed: HashMap<String, PageInfo>,
server_mode: bool,
transcode_enabled: bool,
valid_tag_sources: HashSet<String>,
wikilink_index: Option<Arc<WikilinkIndex>>,
) -> (Vec<Event<'a>>, EventState) {
let mut state = EventState {
root_path: root_path.to_path_buf(),
file_path: file_path.to_path_buf(),
current_media: None,
in_metadata: false,
in_link: false,
metadata_source: None,
metadata_parsed: None,
link_transform_config,
wikilink_index,
prefetched_oembed,
server_mode,
transcode_enabled,
collected_links: Vec::new(),
current_link_dest: None,
current_link_text: String::new(),
valid_tag_sources,
word_count: 0,
in_code_block: false,
sentence_count: 0,
syllable_count: 0,
block_needs_sentence_bump: false,
frontmatter_error: None,
ambiguous_wikilinks: Vec::new(),
};
let expected_len = events.len();
let mut processed_events = Vec::with_capacity(expected_len);
for event in events {
let (processed, new_state) = process_event(event, state);
state = new_state;
processed_events.push(processed);
}
debug_assert_eq!(
processed_events.len(),
expected_len,
"process_event must map one event to one event: `TextLines` addresses \
pass-3 events by their pass-1 index"
);
(processed_events, state)
}
const INCOMPLETE_SPAN_OPEN: &str = "<span class=\"mbr-incomplete\">";
const INCOMPLETE_SPAN_CLOSE: &str = "</span>";
fn open_incomplete_span(line: Option<u32>, highest_anchored_line: &mut u32) -> Event<'static> {
let html = match line {
Some(line) if line > *highest_anchored_line => {
*highest_anchored_line = line;
CowStr::from(format!(
"<span class=\"mbr-incomplete\" id=\"mbr-marker-{line}\">"
))
}
_ => CowStr::Borrowed(INCOMPLETE_SPAN_OPEN),
};
Event::Html(html)
}
fn slice_cow<'a>(text: &CowStr<'a>, range: std::ops::Range<usize>) -> CowStr<'a> {
match text {
CowStr::Borrowed(source) => CowStr::Borrowed(&source[range]),
owned => CowStr::from(owned[range].to_string()),
}
}
fn push_marked_text<'a>(
output: &mut Vec<Event<'a>>,
text: CowStr<'a>,
rule: &MarkerRule,
skip_before: usize,
line: Option<u32>,
highest_anchored_line: &mut u32,
) {
let found: Vec<std::ops::Range<usize>> = rule
.find_iter(&text)
.filter(|range| range.start >= skip_before)
.collect();
if found.is_empty() {
output.push(Event::Text(text));
return;
}
let mut at = 0usize;
for range in found {
if range.start > at {
output.push(Event::Text(slice_cow(&text, at..range.start)));
}
output.push(open_incomplete_span(line, highest_anchored_line));
at = range.end;
output.push(Event::Text(slice_cow(&text, range)));
output.push(Event::Html(CowStr::Borrowed(INCOMPLETE_SPAN_CLOSE)));
}
if at < text.len() {
output.push(Event::Text(slice_cow(&text, at..text.len())));
}
}
fn mark_incomplete_blocks<'a>(
events: Vec<Event<'a>>,
rule: &MarkerRule,
text_lines: &TextLines,
) -> Vec<Event<'a>> {
struct Frame {
start_idx: usize,
has_seen_text: bool,
marker_open: bool,
}
let mut output: Vec<Event<'a>> = Vec::with_capacity(events.len());
let mut stack: Vec<Frame> = Vec::new();
let mut cursor = text_lines.cursor();
let mut code_depth: usize = 0;
let mut image_depth: usize = 0;
let mut highest_anchored_line: u32 = 0;
for (index, event) in events.into_iter().enumerate() {
let event = match event {
Event::Text(text) => {
if code_depth > 0 || image_depth > 0 || stack.is_empty() {
output.push(Event::Text(text));
continue;
}
let line = cursor.line_at(index);
let mut skip_before = 0;
if let Some(top) = stack.last_mut()
&& !top.has_seen_text
{
top.has_seen_text = true;
let indent = text.len() - text.trim_start().len();
if let Some(end) = rule.block_initial_match(text.trim_start()) {
output.insert(
top.start_idx + 1,
open_incomplete_span(line, &mut highest_anchored_line),
);
top.marker_open = true;
skip_before = indent + end;
}
}
push_marked_text(
&mut output,
text,
rule,
skip_before,
line,
&mut highest_anchored_line,
);
continue;
}
other => other,
};
match &event {
Event::Start(Tag::CodeBlock(_)) => {
code_depth += 1;
output.push(event);
}
Event::End(TagEnd::CodeBlock) => {
code_depth = code_depth.saturating_sub(1);
output.push(event);
}
Event::Start(Tag::Image { .. }) => {
image_depth += 1;
output.push(event);
}
Event::End(TagEnd::Image) => {
image_depth = image_depth.saturating_sub(1);
output.push(event);
}
Event::Start(Tag::Paragraph)
| Event::Start(Tag::Heading { .. })
| Event::Start(Tag::Item)
| Event::Start(Tag::TableCell) => {
let start_idx = output.len();
output.push(event);
stack.push(Frame {
start_idx,
has_seen_text: false,
marker_open: false,
});
}
Event::End(TagEnd::Paragraph)
| Event::End(TagEnd::Heading(_))
| Event::End(TagEnd::Item)
| Event::End(TagEnd::TableCell) => {
if let Some(frame) = stack.pop()
&& frame.marker_open
{
output.push(Event::Html(CowStr::Borrowed(INCOMPLETE_SPAN_CLOSE)));
}
output.push(event);
}
_ => {
output.push(event);
}
}
}
output
}
fn finalize_render(
processed_events: Vec<Event<'_>>,
state: EventState,
section_attrs: HashMap<usize, ParsedAttrs>,
markdown_input: &str,
headings: Vec<HeadingInfo>,
has_h1: bool,
) -> Result<MarkdownRenderResult, MarkdownError> {
let mut html_output = String::with_capacity(markdown_input.len() * 2);
let mut seen_targets: HashSet<String> = HashSet::new();
let deduplicated_links: Vec<OutboundLink> = state
.collected_links
.into_iter()
.filter(|link| seen_targets.insert(link.to.clone()))
.collect();
crate::html::push_html_mbr_with_attrs(
&mut html_output,
processed_events.into_iter(),
section_attrs,
);
let mut frontmatter = yaml_frontmatter_simplified(&state.metadata_parsed);
if !frontmatter.contains_key("title")
&& let Some(h1_text) = headings
.first()
.filter(|h| h.level == 1)
.map(|h| h.text.clone())
{
frontmatter.insert("title".to_string(), serde_json::Value::String(h1_text));
}
Ok(MarkdownRenderResult {
frontmatter,
frontmatter_error: state.frontmatter_error,
headings,
html: html_output,
outbound_links: deduplicated_links,
has_h1,
word_count: state.word_count,
sentence_count: state.sentence_count,
syllable_count: state.syllable_count,
ambiguous_wikilinks: state.ambiguous_wikilinks,
})
}
#[allow(clippy::too_many_arguments)]
pub fn render_sync(
file: PathBuf,
root_path: &Path,
oembed_timeout_ms: u64,
link_transform_config: LinkTransformConfig,
oembed_cache: Option<Arc<OembedCache>>,
server_mode: bool,
transcode_enabled: bool,
valid_tag_sources: HashSet<String>,
mark_incomplete: bool,
incomplete_markers: &[String],
wikilink_index: Option<Arc<WikilinkIndex>>,
) -> Result<MarkdownRenderResult, MarkdownError> {
let mut raw_markdown_input =
fs::read_to_string(&file).map_err(|e| MarkdownError::ReadFailed {
path: file.clone(),
source: e,
})?;
strip_bom_in_place(&mut raw_markdown_input);
let markdown_input = if valid_tag_sources.is_empty() {
raw_markdown_input
} else {
transform_wikilinks(&raw_markdown_input, &valid_tag_sources)
};
let marker_rule = mark_incomplete
.then(|| MarkerRule::cached(incomplete_markers))
.flatten();
let mut text_lines = if marker_rule.is_some() {
TextLines::recording()
} else {
TextLines::disabled()
};
let (events_with_ids, headings, section_attrs) =
collect_events_and_headings(&markdown_input, TaskMarkup::Render, &mut text_lines);
let has_h1 = headings.first().is_some_and(|h| h.level == 1);
let mut prefetched_oembed = collect_local_embeds(&events_with_ids);
if oembed_timeout_ms > 0
&& let Some(ref cache) = oembed_cache
{
for (url, info) in collect_cached_oembed(&events_with_ids, cache) {
prefetched_oembed.entry(url).or_insert(info);
}
}
let (processed_events, state) = process_all_events(
events_with_ids,
root_path,
&file,
link_transform_config,
prefetched_oembed,
server_mode,
transcode_enabled,
valid_tag_sources,
wikilink_index,
);
let processed_events = match &marker_rule {
Some(rule) => mark_incomplete_blocks(processed_events, rule, &text_lines),
None => processed_events,
};
finalize_render(
processed_events,
state,
section_attrs,
&markdown_input,
headings,
has_h1,
)
}
pub fn extract_outbound_links_sync(
file: PathBuf,
root_path: &Path,
link_transform_config: LinkTransformConfig,
server_mode: bool,
valid_tag_sources: HashSet<String>,
wikilink_index: Option<Arc<WikilinkIndex>>,
) -> Result<Vec<OutboundLink>, MarkdownError> {
let mut raw_markdown_input =
fs::read_to_string(&file).map_err(|e| MarkdownError::ReadFailed {
path: file.clone(),
source: e,
})?;
strip_bom_in_place(&mut raw_markdown_input);
let markdown_input = if valid_tag_sources.is_empty() {
raw_markdown_input
} else {
transform_wikilinks(&raw_markdown_input, &valid_tag_sources)
};
let (events_with_ids, _headings, _section_attrs) = collect_events_and_headings(
&markdown_input,
TaskMarkup::Skip,
&mut TextLines::disabled(),
);
let prefetched_oembed = collect_local_embeds(&events_with_ids);
let (_processed_events, state) = process_all_events(
events_with_ids,
root_path,
&file,
link_transform_config,
prefetched_oembed,
server_mode,
false, valid_tag_sources,
wikilink_index,
);
let mut seen_targets: HashSet<String> = HashSet::new();
Ok(state
.collected_links
.into_iter()
.filter(|link| seen_targets.insert(link.to.clone()))
.collect())
}
fn collect_local_embeds(events: &[Event<'_>]) -> HashMap<String, PageInfo> {
collect_bare_urls(events)
.into_iter()
.filter_map(|url| PageInfo::local_embed(&url).map(|info| (url, info)))
.collect()
}
fn collect_cached_oembed(events: &[Event<'_>], cache: &OembedCache) -> HashMap<String, PageInfo> {
let urls = collect_bare_urls(events);
let mut results = HashMap::new();
for url in urls {
if let Some(info) = cache.get(&url) {
results.insert(url, info);
}
}
results
}
fn collect_bare_urls(events: &[Event<'_>]) -> HashSet<String> {
let mut urls = HashSet::new();
let mut in_link = false;
let mut in_metadata = false;
let mut in_code_block = false;
for event in events {
match event {
Event::Start(Tag::Link { .. }) => in_link = true,
Event::End(TagEnd::Link) => in_link = false,
Event::Start(Tag::MetadataBlock(_)) => in_metadata = true,
Event::End(TagEnd::MetadataBlock(_)) => in_metadata = false,
Event::Start(Tag::CodeBlock(_)) => in_code_block = true,
Event::End(TagEnd::CodeBlock) => in_code_block = false,
Event::Text(text)
if !in_link
&& !in_metadata
&& !in_code_block
&& text.starts_with("http")
&& !text.contains(' ')
&& !text.trim_start().starts_with("{{") =>
{
urls.insert(text.to_string());
}
_ => {}
}
}
urls
}
const OEMBED_FETCH_CONCURRENCY: usize = 8;
const MAX_OEMBED_FETCHES_PER_DOC: usize = 100;
fn cap_fetch_list(mut urls: Vec<String>) -> Vec<String> {
if urls.len() > MAX_OEMBED_FETCHES_PER_DOC {
tracing::warn!(
"oembed prefetch: {} bare URLs exceeds the per-document cap of {}; \
the remainder will render as plain links",
urls.len(),
MAX_OEMBED_FETCHES_PER_DOC
);
urls.sort_unstable();
urls.truncate(MAX_OEMBED_FETCHES_PER_DOC);
}
urls
}
async fn prefetch_oembed_urls(
events: &[Event<'_>],
oembed_timeout_ms: u64,
oembed_cache: &Option<Arc<OembedCache>>,
) -> HashMap<String, PageInfo> {
let urls = collect_bare_urls(events);
if urls.is_empty() {
return HashMap::new();
}
tracing::debug!("oembed prefetch: found {} bare URLs to fetch", urls.len());
let (cached, uncached): (Vec<_>, Vec<_>) = urls
.into_iter()
.partition(|url| oembed_cache.as_ref().and_then(|c| c.get(url)).is_some());
let mut results = HashMap::new();
if let Some(cache) = oembed_cache {
for url in cached {
if let Some(info) = cache.get(&url) {
results.insert(url, info);
}
}
}
let to_fetch = cap_fetch_list(uncached);
if !to_fetch.is_empty() {
use futures::stream::StreamExt;
tracing::debug!(
"oembed prefetch: {} cached, {} to fetch",
results.len(),
to_fetch.len()
);
let fetched: Vec<_> = futures::stream::iter(to_fetch)
.map(|url| async move {
tracing::debug!("oembed fetch start: {}", url);
let result = PageInfo::new_from_url(&url, oembed_timeout_ms)
.await
.unwrap_or_else(|_| PageInfo {
url: url.clone(),
..Default::default()
});
tracing::debug!("oembed fetch complete: {}", url);
(url, result)
})
.buffer_unordered(OEMBED_FETCH_CONCURRENCY)
.collect()
.await;
for (url, info) in fetched {
if let Some(cache) = oembed_cache {
cache.insert(url.clone(), info.clone());
}
results.insert(url, info);
}
}
results
}
fn yaml_frontmatter_simplified(y: &Option<Yaml>) -> SimpleMetadata {
match y.as_ref().and_then(|yaml| yaml.as_hash()) {
Some(hash) => yaml_hash_to_metadata(hash),
None => SimpleMetadata::new(),
}
}
fn yaml_hash_to_metadata(hash: &yaml_rust2::yaml::Hash) -> SimpleMetadata {
let mut hm = SimpleMetadata::new();
for (k, v) in hash.iter() {
match (k, v) {
(Yaml::String(key), Yaml::String(value)) => {
tracing::trace!("Frontmatter: {key} = {value}");
hm.insert(key.clone(), serde_json::Value::String(value.clone()));
}
(Yaml::String(key), Yaml::Array(vals)) => {
let arr: Vec<serde_json::Value> = vals
.iter()
.filter_map(|val| val.as_str())
.map(|s| serde_json::Value::String(s.to_string()))
.collect();
tracing::trace!("Frontmatter: {key} = {:?}", &arr);
hm.insert(key.clone(), serde_json::Value::Array(arr));
}
(Yaml::String(key), Yaml::Hash(nested_hash)) => {
tracing::trace!("Frontmatter: {key} = (nested hash)");
let nested = yaml_hash_to_metadata(nested_hash);
for (k, v) in nested {
hm.insert(key.to_string() + "." + k.as_str(), v);
}
}
(Yaml::String(key), Yaml::Integer(val)) => {
tracing::trace!("Frontmatter: {key} = {val}");
hm.insert(key.clone(), serde_json::json!(val));
}
(Yaml::String(key), Yaml::Real(val)) => {
tracing::trace!("Frontmatter: {key} = {val}");
hm.insert(key.clone(), serde_json::Value::String(val.clone()));
}
(Yaml::String(key), Yaml::Boolean(val)) => {
tracing::trace!("Frontmatter: {key} = {val}");
hm.insert(key.clone(), serde_json::json!(val));
}
(Yaml::String(key), other_val) => {
tracing::trace!("Frontmatter: {key} = {:?}", &other_val);
if let Some(str_val) = other_val.as_str() {
hm.insert(key.clone(), serde_json::Value::String(str_val.to_string()));
}
}
(k, v) => {
tracing::warn!("Unexpected frontmatter key-value: {:?} = {:?}", k, v);
}
}
}
hm
}
const FRONTMATTER_MAX_BYTES: usize = 8 * 1024;
#[derive(Debug, Clone, Default)]
pub struct FileMetadata {
pub metadata: SimpleMetadata,
pub relationships: Vec<crate::relationships::RawRelationship>,
}
pub fn extract_metadata_from_file<P: AsRef<Path>>(path: P) -> Result<FileMetadata, MarkdownError> {
let path = path.as_ref();
let mut file = File::open(path).map_err(|e| MarkdownError::ReadFailed {
path: path.to_path_buf(),
source: e,
})?;
let file_len = file.metadata().map(|m| m.len() as usize).unwrap_or(0);
let read_len = file_len.min(FRONTMATTER_MAX_BYTES);
let mut buffer = vec![0u8; read_len];
file.read_exact(&mut buffer)
.map_err(|e| MarkdownError::ReadFailed {
path: path.to_path_buf(),
source: e,
})?;
let decoded = String::from_utf8_lossy(&buffer);
let markdown_input = strip_bom(&decoded);
let parser = MDParser::new_ext(markdown_input, Options::ENABLE_YAML_STYLE_METADATA_BLOCKS);
let parser = TextMergeStream::new(parser);
let mut in_metadata = false;
let mut hm = SimpleMetadata::new();
let mut relationships = Vec::new();
for event in parser.take(4) {
match &event {
Event::Start(Tag::MetadataBlock(MetadataBlockKind::YamlStyle)) => {
in_metadata = true;
}
Event::End(TagEnd::MetadataBlock(MetadataBlockKind::YamlStyle)) => {
break;
}
Event::Text(text) if in_metadata => {
let metadata_parsed = match YamlLoader::load_from_str(text) {
Ok(docs) => docs.into_iter().next(),
Err(e) => {
tracing::warn!(
path = %path.display(),
"Failed to parse YAML frontmatter: {e}; the whole \
frontmatter block (including any `aliases` and \
`relationships`) is ignored for this note"
);
None
}
};
if let Some(ref yaml) = metadata_parsed {
relationships = crate::relationships::parse_relationships(yaml);
}
hm = yaml_frontmatter_simplified(&metadata_parsed);
break;
}
_ => {}
}
}
if !hm.contains_key("title")
&& let Some(h1_text) = extract_first_h1(markdown_input)
{
hm.insert("title".to_string(), serde_json::Value::String(h1_text));
}
Ok(FileMetadata {
metadata: hm,
relationships,
})
}
pub(crate) fn slugify(text: &str) -> String {
text.to_lowercase()
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' {
c
} else if c.is_whitespace() {
'-'
} else {
' '
}
})
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join("-")
}
fn generate_anchor_id(text: &str, anchor_ids: &mut HashMap<String, usize>) -> String {
let base_id = slugify(text);
let base_id = if base_id.is_empty() {
"heading".to_string()
} else {
base_id
};
let mut count = anchor_ids.get(&base_id).copied().unwrap_or(0);
let candidate = loop {
count += 1;
let candidate = if count == 1 {
base_id.clone()
} else {
format!("{}-{}", base_id, count)
};
if !anchor_ids.contains_key(&candidate) {
break candidate;
}
};
anchor_ids.insert(base_id, count);
anchor_ids.entry(candidate.clone()).or_insert(0);
candidate
}
fn process_event(
event: pulldown_cmark::Event<'_>,
mut state: EventState,
) -> (pulldown_cmark::Event<'_>, EventState) {
match &event {
Event::Start(Tag::Image {
link_type,
dest_url,
title,
id,
}) => {
let transformed_url = transform_link(dest_url, &state.link_transform_config);
match MediaEmbed::from_url_and_title(&transformed_url, title) {
Some(media) => {
let html = media.to_html(true, state.server_mode, state.transcode_enabled);
state.current_media = Some(media);
(Event::Html(html.into()), state)
}
_ => {
let new_event = Event::Start(Tag::Image {
link_type: *link_type,
dest_url: CowStr::from(transformed_url),
title: title.clone(),
id: id.clone(),
});
(new_event, state)
}
}
}
Event::End(TagEnd::Image) => {
if let Some(media) = state.current_media.take() {
(Event::Html(media.html_close().into()), state)
} else {
(event, state)
}
}
Event::Start(Tag::MetadataBlock(v)) => {
state.metadata_source = Some(*v);
state.in_metadata = true;
(event.clone(), state)
}
Event::End(TagEnd::MetadataBlock(_)) => {
state.in_metadata = false;
(event.clone(), state)
}
Event::Start(Tag::Link {
link_type,
dest_url,
title,
id,
}) => {
state.in_link = true;
state.current_link_dest = Some(dest_url.to_string());
state.current_link_text.clear();
let transformed_url =
if let Some(wikilink) = parse_tag_link(dest_url, &state.valid_tag_sources) {
transform_link(&wikilink.url_path(), &state.link_transform_config)
} else {
let is_bare_wikilink =
matches!(link_type, LinkType::WikiLink { .. }) && !dest_url.contains('/');
let global = if is_bare_wikilink {
state.wikilink_index.as_ref().and_then(|idx| {
idx.resolve_wikilink(
dest_url,
&state.link_transform_config.current_page_url,
state.link_transform_config.is_index_file,
)
})
} else {
None
};
let ambiguous = if is_bare_wikilink {
state.wikilink_index.as_ref().and_then(|idx| {
idx.ambiguity_for(
dest_url,
&state.link_transform_config.current_page_url,
state.link_transform_config.is_index_file,
)
})
} else {
None
};
if let Some(found) = ambiguous
&& !state.ambiguous_wikilinks.contains(&found)
{
state.ambiguous_wikilinks.push(found);
}
match global {
Some(abs) => {
state.current_link_dest = Some(abs.clone());
transform_link(&abs, &state.link_transform_config)
}
None => transform_link(dest_url, &state.link_transform_config),
}
};
let new_event = Event::Start(Tag::Link {
link_type: *link_type,
dest_url: CowStr::from(transformed_url),
title: title.clone(),
id: id.clone(),
});
(new_event, state)
}
Event::End(TagEnd::Link) => {
state.in_link = false;
if let Some(dest_url) = state.current_link_dest.take() {
let (path, anchor) = split_url_anchor(&dest_url);
let internal = is_internal_link(&dest_url);
let link = OutboundLink {
to: path,
text: std::mem::take(&mut state.current_link_text),
anchor,
internal,
};
state.collected_links.push(link);
}
(event, state)
}
Event::Start(Tag::CodeBlock(_)) => {
state.in_code_block = true;
(event, state)
}
Event::End(TagEnd::CodeBlock) => {
state.in_code_block = false;
(event, state)
}
Event::End(TagEnd::Paragraph | TagEnd::Heading(_) | TagEnd::Item) => {
if state.block_needs_sentence_bump {
state.sentence_count += 1;
state.block_needs_sentence_bump = false;
}
(event, state)
}
Event::Text(text) => {
if state.in_link {
state.current_link_text.push_str(text);
}
if !state.in_metadata && !state.in_code_block {
for word in text.split_whitespace() {
state.word_count += 1;
state.syllable_count += crate::readability::count_syllables(word);
}
let (sentences_in_text, ends_with_terminator) = count_sentence_terminators(text);
state.sentence_count += sentences_in_text;
let trimmed = text.trim_end();
if !trimmed.is_empty() {
state.block_needs_sentence_bump = !ends_with_terminator;
}
}
if state.in_metadata {
match YamlLoader::load_from_str(text) {
Ok(docs) => state.metadata_parsed = docs.into_iter().next(),
Err(e) => {
tracing::warn!(
path = %state.file_path.display(),
"Failed to parse YAML frontmatter: {e}"
);
state.frontmatter_error = Some(e.to_string());
}
}
(event, state)
} else if state.in_code_block {
(event, state)
} else if !state.in_link && text.starts_with("http") && !text.contains(' ') {
let url_str = text.to_string();
let info = state
.prefetched_oembed
.get(&url_str)
.cloned()
.unwrap_or_else(|| PageInfo {
url: url_str,
..Default::default()
});
(Event::Html(info.html().into()), state)
} else if text.trim_start().starts_with("{{") {
if let Some(mut vid) = Vid::from_vid(text) {
vid.url = transform_link(&vid.url, &state.link_transform_config);
(
Event::Html(
vid.to_html(false, state.server_mode, state.transcode_enabled)
.into(),
),
state,
)
} else {
(event, state)
}
} else {
(event, state)
}
}
_ => (event, state),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
async fn render_markdown(content: &str) -> String {
render_markdown_with_config(content, false, HashSet::new()).await
}
async fn render_markdown_with_tags(content: &str, tag_sources: HashSet<String>) -> String {
render_markdown_with_config(content, false, tag_sources).await
}
async fn render_markdown_with_mode(content: &str, server_mode: bool) -> String {
let mut file = NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
let path = file.path().to_path_buf();
let root = path.parent().unwrap().to_path_buf();
let config = LinkTransformConfig {
markdown_extensions: vec!["md".to_string()],
index_file: "index.md".to_string(),
is_index_file: false,
url_depth: None,
current_page_url: String::new(),
markdown_page_probe: None,
};
render(
path,
&root,
0,
config,
server_mode,
false,
HashSet::new(),
false,
&[],
None,
)
.await
.unwrap()
.html
}
async fn render_markdown_with_config(
content: &str,
is_index_file: bool,
tag_sources: HashSet<String>,
) -> String {
let mut file = NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
let path = file.path().to_path_buf();
let root = path.parent().unwrap().to_path_buf();
let config = LinkTransformConfig {
markdown_extensions: vec!["md".to_string()],
index_file: "index.md".to_string(),
is_index_file,
url_depth: None,
current_page_url: String::new(),
markdown_page_probe: None,
};
let result = render(
path,
&root,
100,
config,
false,
false,
tag_sources,
false,
&[],
None,
)
.await
.unwrap();
result.html
}
async fn render_markdown_marked(content: &str, markers: &[&str]) -> String {
render_result_marked(content, markers).await.html
}
async fn render_result_marked(content: &str, markers: &[&str]) -> MarkdownRenderResult {
let mut file = NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
let path = file.path().to_path_buf();
let root = path.parent().unwrap().to_path_buf();
let config = LinkTransformConfig {
markdown_extensions: vec!["md".to_string()],
index_file: "index.md".to_string(),
is_index_file: false,
url_depth: None,
current_page_url: String::new(),
markdown_page_probe: None,
};
let owned: Vec<String> = markers.iter().map(|s| s.to_string()).collect();
render(
path,
&root,
0,
config,
false,
false,
HashSet::new(),
true,
&owned,
None,
)
.await
.unwrap()
}
async fn render_result(content: &str) -> MarkdownRenderResult {
let mut file = NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
let path = file.path().to_path_buf();
let root = path.parent().unwrap().to_path_buf();
let config = LinkTransformConfig {
markdown_extensions: vec!["md".to_string()],
index_file: "index.md".to_string(),
is_index_file: false,
url_depth: None,
current_page_url: String::new(),
markdown_page_probe: None,
};
render(
path,
&root,
0,
config,
false,
false,
HashSet::new(),
false,
&[],
None,
)
.await
.unwrap()
}
async fn render_with_wikilinks(
content: &str,
current_page_url: &str,
url_depth: Option<usize>,
wikilink_index: Option<Arc<WikilinkIndex>>,
) -> MarkdownRenderResult {
let mut file = NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
let path = file.path().to_path_buf();
let root = path.parent().unwrap().to_path_buf();
let config = LinkTransformConfig {
markdown_extensions: vec!["md".to_string()],
index_file: "index.md".to_string(),
is_index_file: false,
url_depth,
current_page_url: current_page_url.to_string(),
markdown_page_probe: None,
};
render(
path,
&root,
0,
config,
true, false,
HashSet::new(),
false,
&[],
wikilink_index,
)
.await
.unwrap()
}
fn wikilink_note(url: &str, title: &str, stem: &str) -> crate::relationships::NoteRelInput {
crate::relationships::NoteRelInput {
url: url.to_string(),
title: title.to_string(),
stem: stem.to_string(),
aliases: Vec::new(),
is_index: false,
relationships: Vec::new(),
}
}
#[tokio::test]
async fn wikilink_global_fallback_rewrites_href_and_records_absolute_target() {
let index = Arc::new(WikilinkIndex::new());
index.rebuild(&[
wikilink_note("/walsh/patrick-walsh/", "Patrick Walsh", "patrick-walsh"),
wikilink_note("/notes/family/", "Family", "family"),
]);
let result = render_with_wikilinks(
"See [[Patrick Walsh]] here.",
"/notes/family/",
None, Some(index),
)
.await;
assert!(
result.html.contains(r#"href="/walsh/patrick-walsh/""#),
"expected absolute href, got: {}",
result.html
);
assert_eq!(
result.outbound_links[0].to, "/walsh/patrick-walsh/",
"outbound target should be the absolute URL for validation/backlinks"
);
}
#[tokio::test]
async fn wikilink_same_folder_keeps_default_transform() {
let index = Arc::new(WikilinkIndex::new());
index.rebuild(&[
wikilink_note("/notes/patrick-walsh/", "Patrick Walsh", "patrick-walsh"),
wikilink_note("/notes/family/", "Family", "family"),
]);
let result = render_with_wikilinks(
"See [[patrick-walsh]] here.",
"/notes/family/",
None,
Some(index),
)
.await;
assert!(
!result.html.contains(r#"href="/notes/patrick-walsh/""#),
"same-folder wikilink must not be rewritten to absolute: {}",
result.html
);
assert!(
result.html.contains(r#"href="../patrick-walsh""#),
"expected default relative href, got: {}",
result.html
);
assert_eq!(result.outbound_links[0].to, "patrick-walsh");
}
#[tokio::test]
async fn invalid_yaml_frontmatter_is_captured_not_swallowed() {
let content =
"---\ntitle: \"Hi\"\nstyle: slides\ntags:\n\t* presentation\n\t* ai\n---\n# Heading\n";
let result = render_result(content).await;
assert!(
result.frontmatter_error.is_some(),
"expected a captured frontmatter parse error, got None"
);
assert!(
!result.frontmatter.contains_key("style"),
"expected style to be discarded when frontmatter fails to parse"
);
}
#[tokio::test]
async fn valid_yaml_frontmatter_has_no_error() {
let content = "---\ntitle: \"Hi\"\nstyle: slides\n---\n# Heading\n";
let result = render_result(content).await;
assert!(result.frontmatter_error.is_none());
assert!(result.frontmatter.contains_key("style"));
}
const DUPLICATE_KEY_FRONTMATTER: &str = concat!(
"---\n",
"type: person\n",
"aliases:\n",
" - Johnny Doe\n",
"relationships:\n",
" - type: parent\n",
" to: \"[[Mary Doe]]\"\n",
" to: \"[[Sam Doe]]\"\n",
"---\n",
"# John Doe\n",
);
#[test]
fn duplicate_frontmatter_key_warning_names_the_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("john-doe.md");
std::fs::write(&path, DUPLICATE_KEY_FRONTMATTER).unwrap();
let (result, logs) =
crate::test_support::capture_tracing(|| extract_metadata_from_file(&path));
let meta = result.expect("extraction must still succeed");
assert!(meta.relationships.is_empty());
assert!(!meta.metadata.contains_key("type"));
assert!(!meta.metadata.contains_key("aliases"));
assert!(
logs.contains("Failed to parse YAML frontmatter"),
"expected a frontmatter warning, got: {logs}"
);
assert!(
logs.contains("john-doe.md"),
"the warning must name the file, got: {logs}"
);
}
#[test]
fn render_frontmatter_warning_names_the_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("broken-note.md");
std::fs::write(&path, DUPLICATE_KEY_FRONTMATTER).unwrap();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let (result, logs) = crate::test_support::capture_tracing(|| {
runtime.block_on(render(
path.clone(),
dir.path(),
0,
LinkTransformConfig {
markdown_extensions: vec!["md".to_string()],
index_file: "index.md".to_string(),
is_index_file: false,
url_depth: None,
current_page_url: "/broken-note/".to_string(),
markdown_page_probe: None,
},
true,
false,
HashSet::new(),
false,
&[],
None,
))
});
let result = result.expect("render must still succeed");
assert!(result.frontmatter_error.is_some());
assert!(
logs.contains("Failed to parse YAML frontmatter"),
"expected a frontmatter warning, got: {logs}"
);
assert!(
logs.contains("broken-note.md"),
"the warning must name the file, got: {logs}"
);
}
#[tokio::test]
async fn ambiguous_body_wikilink_is_reported_without_changing_resolution() {
let index = Arc::new(WikilinkIndex::new());
index.rebuild(&[
wikilink_note("/people/john-jr/", "John Doe", "john-jr"),
wikilink_note("/people/john-sr/", "John Doe", "john-sr"),
]);
let result = render_with_wikilinks(
"His father was [[John Doe]], and also [[John Doe]] again.",
"/notes/family/",
None,
Some(index),
)
.await;
assert!(
result.html.contains(r#"href="/people/john-jr/""#),
"expected the first-wins target, got: {}",
result.html
);
assert_eq!(result.ambiguous_wikilinks.len(), 1);
let found = &result.ambiguous_wikilinks[0];
assert_eq!(found.raw, "[[John Doe]]");
assert_eq!(found.resolved_to, "/people/john-jr/");
assert_eq!(found.candidates, vec!["/people/john-sr/".to_string()]);
}
#[tokio::test]
async fn unambiguous_body_wikilink_reports_nothing() {
let index = Arc::new(WikilinkIndex::new());
index.rebuild(&[
wikilink_note("/people/john/", "John Doe", "john"),
wikilink_note("/notes/family/", "Family", "family"),
]);
let result = render_with_wikilinks(
"See [[John Doe]] here.",
"/notes/family/",
None,
Some(index),
)
.await;
assert!(result.ambiguous_wikilinks.is_empty());
}
#[tokio::test]
async fn wikilink_ambiguity_is_empty_without_an_index() {
let result = render_result("See [[Anyone]] here.").await;
assert!(result.ambiguous_wikilinks.is_empty());
}
#[test]
fn extract_metadata_from_file_returns_relationships() {
let mut file = NamedTempFile::new().unwrap();
writeln!(
file,
"---\ntype: person\nborn: 1901-05-02\nrelationships:\n - type: child\n from: \"[[Sam Doe]]\"\n---\n# John\n"
)
.unwrap();
let result = extract_metadata_from_file(file.path()).unwrap();
assert_eq!(
result.metadata.get("type"),
Some(&serde_json::Value::String("person".to_string()))
);
assert_eq!(result.relationships.len(), 1);
assert_eq!(result.relationships[0].rel_type, "child");
assert_eq!(result.relationships[0].from.as_deref(), Some("[[Sam Doe]]"));
}
#[test]
fn sentence_terminator_basic_cases() {
assert_eq!(count_sentence_terminators(""), (0, false));
assert_eq!(count_sentence_terminators("Hello."), (1, true));
assert_eq!(count_sentence_terminators("Hi! How are you?"), (2, true));
assert_eq!(count_sentence_terminators("Wait..."), (1, true));
assert_eq!(count_sentence_terminators("v1.2.3 is out."), (1, true));
assert_eq!(count_sentence_terminators("No ending here"), (0, false));
}
#[tokio::test]
async fn readability_counts_simple_paragraph() {
let md = "The cat sat on the mat. The dog ran away.";
let result = render_result(md).await;
assert_eq!(result.word_count, 10);
assert_eq!(result.sentence_count, 2);
assert_eq!(result.syllable_count, 11);
}
#[tokio::test]
async fn readability_heading_without_terminator_bumps_sentence() {
let md = "# Introduction\n\nHello world.";
let result = render_result(md).await;
assert_eq!(result.word_count, 3);
assert_eq!(result.sentence_count, 2);
}
#[tokio::test]
async fn readability_excludes_code_blocks() {
let md = "Some prose here.\n\n```rust\nfn main() { println!(\"hi\"); }\n```\n";
let result = render_result(md).await;
assert_eq!(result.word_count, 3);
assert_eq!(result.sentence_count, 1);
}
#[tokio::test]
async fn readability_empty_document_has_zero_counts() {
let result = render_result("").await;
assert_eq!(result.word_count, 0);
assert_eq!(result.sentence_count, 0);
assert_eq!(result.syllable_count, 0);
}
fn task_body(html: &str) -> String {
let start = html.find("<li>").expect("a list item") + "<li>".len();
let end = html.find("</li>").expect("a closed list item");
html[start..end].to_string()
}
#[tokio::test]
async fn canceled_marker_renders_a_checkbox_and_a_status_class() {
for md in ["- [-] canceled task", "* [-] canceled task"] {
assert_eq!(
task_body(&render_markdown(md).await),
concat!(
r#"<input type="checkbox" class="mbr-task-check" id="mbr-task-1" "#,
r#"data-mbr-task-line="1" data-mbr-task-status="canceled" disabled>"#,
r#"<span class="mbr-task-text mbr-task-canceled">canceled task</span>"#
),
"for {md:?}"
);
}
}
#[tokio::test]
async fn moved_marker_is_canceled_and_shows_its_destination_date() {
let html = render_markdown("- [>] moved along > 2026-08-04").await;
assert_eq!(
task_body(&html),
concat!(
r#"<input type="checkbox" class="mbr-task-check" id="mbr-task-1" "#,
r#"data-mbr-task-line="1" data-mbr-task-status="canceled" disabled>"#,
r#"<span class="mbr-task-text mbr-task-canceled">moved along</span>"#,
r#" <time class="mbr-task-moved" datetime="2026-08-04">Aug 4</time>"#
)
);
}
#[tokio::test]
async fn unchecked_and_checked_markers_carry_their_status() {
let open = render_markdown("- [ ] unchecked item").await;
assert!(
open.contains(r#"data-mbr-task-status="open" disabled>"#),
"{open}"
);
assert!(!open.contains(" checked"), "{open}");
let done = render_markdown("- [x] checked item").await;
assert!(
done.contains(r#"data-mbr-task-status="done" checked disabled>"#),
"{done}"
);
}
#[tokio::test]
async fn task_text_is_html_escaped() {
let html = render_markdown("- [-] special chars: & < > \"").await;
assert!(html.contains("special chars: & < >"), "{html}");
}
#[tokio::test]
async fn checkboxes_are_inert_in_every_mode() {
for server_mode in [false, true] {
let html = render_markdown_with_mode("- [ ] a task", server_mode).await;
assert!(html.contains(" disabled>"), "server_mode={server_mode}");
}
}
#[tokio::test]
async fn annotations_render_as_chips_instead_of_literal_text() {
let html = render_markdown(
"- [ ] write the report !!! #work @due(2026-08-05) @done(2026-08-04 12:11 PM)",
)
.await;
assert_eq!(
task_body(&html),
concat!(
r#"<input type="checkbox" class="mbr-task-check" id="mbr-task-1" "#,
r#"data-mbr-task-line="1" data-mbr-task-status="open" disabled>"#,
r#"<span class="mbr-task-text">write the report</span>"#,
r#" <span class="mbr-task-pri mbr-task-pri-urgent" role="img" "#,
r#"aria-label="Urgent priority" title="Urgent priority"></span>"#,
r#" <span class="mbr-task-tag">#work</span>"#,
r#" <time class="mbr-task-due" datetime="2026-08-05">Aug 5</time>"#,
r#" <time class="mbr-task-completed" datetime="2026-08-04T12:11">Aug 4, 12:11 PM</time>"#
)
);
}
#[tokio::test]
async fn a_task_with_no_annotations_emits_no_chips() {
let html = render_markdown("- [ ] plain task").await;
assert_eq!(
task_body(&html),
concat!(
r#"<input type="checkbox" class="mbr-task-check" id="mbr-task-1" "#,
r#"data-mbr-task-line="1" data-mbr-task-status="open" disabled>"#,
r#"<span class="mbr-task-text">plain task</span>"#
)
);
}
#[tokio::test]
async fn inline_formatting_survives_annotation_stripping() {
let html = render_markdown("- [ ] fix **this** and *that* #bug").await;
assert!(
html.contains(
r#"<span class="mbr-task-text">fix <strong>this</strong> and <em>that</em></span>"#
),
"inline formatting and its spacing must survive: {html}"
);
assert!(
html.contains(r#"<span class="mbr-task-tag">#bug</span>"#),
"{html}"
);
}
#[tokio::test]
async fn links_inside_a_task_keep_working() {
let html = render_markdown("- [ ] read [the guide](guide.md) !! @due(2026-08-05)").await;
assert!(
html.contains(r#"<a href="../guide/">the guide</a>"#),
"{html}"
);
assert!(html.contains("mbr-task-pri-high"), "{html}");
assert!(!html.contains("@due("), "{html}");
}
#[tokio::test]
async fn moved_from_marker_is_stripped_without_a_chip() {
let html = render_markdown("- [ ] carried over < 2026-08-01").await;
assert!(html.contains(">carried over</span>"), "{html}");
assert!(!html.contains("2026-08-01"), "{html}");
}
#[tokio::test]
async fn nested_subtasks_each_get_their_own_line_number() {
let html = render_markdown("- [ ] parent\n\t- [ ] child one\n\t- [x] child two").await;
for line in 1..=3 {
assert!(
html.contains(&format!(r#"data-mbr-task-line="{line}""#)),
"missing line {line}: {html}"
);
}
assert!(
html.contains("<span class=\"mbr-task-text\">parent</span>\n<ul>"),
"the parent's text span must close before its subtask list: {html}"
);
}
#[tokio::test]
async fn a_marker_inside_a_fenced_code_block_is_left_alone() {
let html = render_result("```\n- [-] not a checkbox\n- [ ] nor this\n```\n")
.await
.html;
assert!(!html.contains("mbr-task-check"), "{html}");
assert!(html.contains("- [-] not a checkbox"), "{html}");
assert!(html.contains("- [ ] nor this"), "{html}");
}
#[tokio::test]
async fn a_bracket_marker_outside_a_list_item_is_not_a_task() {
let html = render_result("[-] this is just a sentence\n").await.html;
assert!(!html.contains("mbr-task-check"), "{html}");
assert!(html.contains("[-] this is just a sentence"), "{html}");
}
fn rendered_task_lines(html: &str) -> Vec<u32> {
const ATTR: &str = "data-mbr-task-line=\"";
html.match_indices(ATTR)
.map(|(at, _)| {
let rest = &html[at + ATTR.len()..];
let end = rest.find('"').expect("unterminated attribute");
rest[..end].parse().expect("numeric line")
})
.collect()
}
#[tokio::test]
async fn task_line_numbers_survive_intervening_blocks() {
let md = concat!(
"---\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", );
let html = render_result(md).await.html;
assert_eq!(rendered_task_lines(&html), vec![7, 15, 16]);
assert_eq!(
rendered_task_lines(&html),
crate::tasks::scan_source_tasks(md)
.into_iter()
.map(|task| task.line)
.collect::<Vec<_>>(),
"the renderer and the task index must agree about line numbers"
);
}
#[tokio::test]
async fn crlf_line_endings_do_not_shift_task_line_numbers() {
let md = "- [ ] first\r\n\r\nprose\r\n\r\n- [x] second\r\n";
let html = render_result(md).await.html;
assert_eq!(rendered_task_lines(&html), vec![1, 5]);
}
#[tokio::test]
async fn incomplete_markers_still_fire_inside_a_task() {
let html = render_markdown_marked("- [ ] TODO: write it up #docs", &["TODO"]).await;
assert!(html.contains(INCOMPLETE_SPAN_PREFIX), "{html}");
assert!(html.contains("TODO: write it up"), "{html}");
assert!(
html.contains(r#"<span class="mbr-task-tag">#docs</span>"#),
"{html}"
);
assert_eq!(incomplete_span_count(&html), 1, "{html}");
assert!(html.contains(r#"id="mbr-task-1""#), "{html}");
assert!(html.contains(r#"id="mbr-marker-1""#), "{html}");
}
#[tokio::test]
async fn wikilinks_on_a_task_line_do_not_shift_its_line_number() {
let sources: HashSet<String> = ["Tags".to_string()].into_iter().collect();
let md = concat!(
"- [ ] read about [[Tags:rust]] #study\n",
"- [ ] and [[Tags:async]] too\n",
"\n",
"- [x] last one\n",
);
let html = render_markdown_with_tags(md, sources).await;
assert_eq!(rendered_task_lines(&html), vec![1, 2, 4]);
assert!(html.contains("href=\"/tags/rust/\""), "{html}");
assert!(
html.contains(r#"<span class="mbr-task-tag">#study</span>"#),
"{html}"
);
}
#[tokio::test]
async fn a_multi_line_tag_wikilink_does_not_shift_later_line_numbers() {
let sources: HashSet<String> = ["Tags".to_string()].into_iter().collect();
let md = "- [ ] see [[Tags:\nrust]] here\n- [x] second\n";
let expected: Vec<u32> = crate::tasks::scan_source_tasks(md)
.into_iter()
.map(|task| task.line)
.collect();
assert_eq!(expected, vec![1, 3]);
let html = render_markdown_with_tags(md, sources).await;
assert_eq!(
rendered_task_lines(&html),
expected,
"the renderer and the task index must agree about line numbers"
);
assert!(!html.contains("/tags/rust/"), "{html}");
}
#[test]
fn line_index_maps_offsets_to_one_based_lines() {
let index = LineIndex::build("ab\nc\n\nd");
assert_eq!(index.line_of(0), 1);
assert_eq!(index.line_of(2), 1); assert_eq!(index.line_of(3), 2);
assert_eq!(index.line_of(5), 3); assert_eq!(index.line_of(6), 4);
assert_eq!(index.line_of(999), 4);
assert_eq!(LineIndex::build("").line_of(0), 1);
}
#[test]
fn split_extended_marker_requires_whitespace_after_the_box() {
assert_eq!(
split_extended_marker("[-] canceled"),
Some((TaskStatus::Canceled, "canceled"))
);
assert_eq!(
split_extended_marker("[>]\tmoved"),
Some((TaskStatus::Canceled, "moved"))
);
assert_eq!(
split_extended_marker("[-]"),
Some((TaskStatus::Canceled, ""))
);
for text in ["[-]x", "[x] done", "[ ] open", "[?] what", "prose"] {
assert_eq!(split_extended_marker(text), None, "for {text:?}");
}
}
#[tokio::test]
async fn test_yaml_frontmatter() {
let md = "---\ntitle: Test Title\n---\n\n# Heading";
let mut file = NamedTempFile::new().unwrap();
file.write_all(md.as_bytes()).unwrap();
let path = file.path().to_path_buf();
let root = path.parent().unwrap().to_path_buf();
let config = LinkTransformConfig {
markdown_extensions: vec!["md".to_string()],
index_file: "index.md".to_string(),
is_index_file: false,
url_depth: None,
current_page_url: String::new(),
markdown_page_probe: None,
};
let result = render(
path,
&root,
100,
config,
false,
false,
HashSet::new(),
false,
&[],
None,
)
.await
.unwrap();
assert_eq!(
result.frontmatter.get("title"),
Some(&serde_json::Value::String("Test Title".to_string()))
);
}
#[test]
fn test_extract_first_h1_basic() {
let md = "# Hello World\n\nSome content";
let result = extract_first_h1(md);
assert_eq!(result, Some("Hello World".to_string()));
}
#[test]
fn test_extract_first_h1_with_inline_formatting() {
let md = "# Hello **World**\n\nSome content";
let result = extract_first_h1(md);
assert_eq!(result, Some("Hello World".to_string()));
}
#[test]
fn test_extract_first_h1_none_when_no_h1() {
let md = "## This is H2\n\nSome content";
let result = extract_first_h1(md);
assert_eq!(result, None);
}
#[test]
fn test_extract_first_h1_returns_first_only() {
let md = "# First H1\n\n# Second H1";
let result = extract_first_h1(md);
assert_eq!(result, Some("First H1".to_string()));
}
#[test]
fn test_extract_first_h1_empty_doc() {
let md = "";
let result = extract_first_h1(md);
assert_eq!(result, None);
}
#[tokio::test]
async fn test_has_h1_true_when_first_heading_is_h1() {
let md = "# Main Title\n\n## Subsection";
let mut file = NamedTempFile::new().unwrap();
file.write_all(md.as_bytes()).unwrap();
let path = file.path().to_path_buf();
let root = path.parent().unwrap().to_path_buf();
let config = LinkTransformConfig {
markdown_extensions: vec!["md".to_string()],
index_file: "index.md".to_string(),
is_index_file: false,
url_depth: None,
current_page_url: String::new(),
markdown_page_probe: None,
};
let result = render(
path,
&root,
100,
config,
false,
false,
HashSet::new(),
false,
&[],
None,
)
.await
.unwrap();
assert!(result.has_h1);
}
#[tokio::test]
async fn test_has_h1_false_when_first_heading_is_h2() {
let md = "## Subsection\n\n# Late H1";
let mut file = NamedTempFile::new().unwrap();
file.write_all(md.as_bytes()).unwrap();
let path = file.path().to_path_buf();
let root = path.parent().unwrap().to_path_buf();
let config = LinkTransformConfig {
markdown_extensions: vec!["md".to_string()],
index_file: "index.md".to_string(),
is_index_file: false,
url_depth: None,
current_page_url: String::new(),
markdown_page_probe: None,
};
let result = render(
path,
&root,
100,
config,
false,
false,
HashSet::new(),
false,
&[],
None,
)
.await
.unwrap();
assert!(!result.has_h1);
}
#[tokio::test]
async fn test_title_fallback_from_h1() {
let md = "# My Document Title\n\nSome content here.";
let mut file = NamedTempFile::new().unwrap();
file.write_all(md.as_bytes()).unwrap();
let path = file.path().to_path_buf();
let root = path.parent().unwrap().to_path_buf();
let config = LinkTransformConfig {
markdown_extensions: vec!["md".to_string()],
index_file: "index.md".to_string(),
is_index_file: false,
url_depth: None,
current_page_url: String::new(),
markdown_page_probe: None,
};
let result = render(
path,
&root,
100,
config,
false,
false,
HashSet::new(),
false,
&[],
None,
)
.await
.unwrap();
assert!(result.has_h1);
assert_eq!(
result.frontmatter.get("title"),
Some(&serde_json::Value::String("My Document Title".to_string()))
);
}
#[tokio::test]
async fn test_frontmatter_title_takes_precedence() {
let md = "---\ntitle: Frontmatter Title\n---\n\n# H1 Title";
let mut file = NamedTempFile::new().unwrap();
file.write_all(md.as_bytes()).unwrap();
let path = file.path().to_path_buf();
let root = path.parent().unwrap().to_path_buf();
let config = LinkTransformConfig {
markdown_extensions: vec!["md".to_string()],
index_file: "index.md".to_string(),
is_index_file: false,
url_depth: None,
current_page_url: String::new(),
markdown_page_probe: None,
};
let result = render(
path,
&root,
100,
config,
false,
false,
HashSet::new(),
false,
&[],
None,
)
.await
.unwrap();
assert!(result.has_h1);
assert_eq!(
result.frontmatter.get("title"),
Some(&serde_json::Value::String("Frontmatter Title".to_string()))
);
}
#[tokio::test]
async fn test_no_title_when_no_frontmatter_and_no_h1() {
let md = "## Subsection\n\nSome content.";
let mut file = NamedTempFile::new().unwrap();
file.write_all(md.as_bytes()).unwrap();
let path = file.path().to_path_buf();
let root = path.parent().unwrap().to_path_buf();
let config = LinkTransformConfig {
markdown_extensions: vec!["md".to_string()],
index_file: "index.md".to_string(),
is_index_file: false,
url_depth: None,
current_page_url: String::new(),
markdown_page_probe: None,
};
let result = render(
path,
&root,
100,
config,
false,
false,
HashSet::new(),
false,
&[],
None,
)
.await
.unwrap();
assert!(!result.has_h1);
assert_eq!(result.frontmatter.get("title"), None);
}
#[tokio::test]
async fn test_video_embed_from_image_syntax() {
let md = "";
let html = render_markdown(md).await;
assert!(html.contains("<video"));
assert!(html.contains("video.mp4"));
assert!(html.contains("<figcaption>"));
assert!(html.contains("My Video"));
assert!(html.contains("</figcaption></figure>"));
}
#[tokio::test]
async fn test_vid_shortcode_apostrophe_path_not_curly_encoded() {
let html = render_markdown("{{ vid(path=\"World's Best.mp4\") }}").await;
assert!(html.contains("World%27s%20Best.mp4"), "got: {html}");
assert!(
!html.contains("%E2%80%99"),
"curly apostrophe leaked into URL: {html}"
);
}
#[tokio::test]
async fn test_audio_embed_from_image_syntax() {
let md = "";
let html = render_markdown(md).await;
assert!(html.contains("<audio"));
assert!(html.contains("audio-embed"));
assert!(html.contains("podcast.mp3"));
assert!(html.contains("<figcaption>"));
assert!(html.contains("Episode 1"));
assert!(html.contains("</figcaption></figure>"));
}
#[tokio::test]
async fn test_youtube_embed_from_image_syntax() {
let md = "";
let html = render_markdown(md).await;
assert!(html.contains("youtube-embed"));
assert!(html.contains("youtube-nocookie.com/embed/dQw4w9WgXcQ"));
assert!(html.contains("<figcaption>"));
assert!(html.contains("Watch this"));
assert!(html.contains("</figcaption></figure>"));
}
#[tokio::test]
async fn test_youtube_short_url_embed() {
let md = "";
let html = render_markdown(md).await;
assert!(html.contains("youtube-embed"));
assert!(html.contains("youtube-nocookie.com/embed/dQw4w9WgXcQ"));
}
#[tokio::test]
async fn test_pdf_embed_from_image_syntax() {
let md = "";
let html = render_markdown(md).await;
assert!(html.contains("pdf-embed"));
assert!(
html.contains(r#"data="../report.pdf""#),
"PDF URL should be transformed. Got: {}",
html
);
assert!(html.contains(r#"type="application/pdf""#));
assert!(html.contains("data-pdf-fallback"));
assert!(html.contains("<figcaption>"));
assert!(html.contains("Important Document"));
assert!(html.contains("</figcaption></figure>"));
}
#[tokio::test]
async fn test_pdf_embed_with_path() {
let md = "";
let html = render_markdown(md).await;
assert!(html.contains("pdf-embed"));
assert!(
html.contains(r#"data="../docs/manual.pdf""#),
"PDF URL should be transformed. Got: {}",
html
);
}
#[tokio::test]
async fn test_regular_image_not_converted() {
let md = "";
let html = render_markdown(md).await;
assert!(html.contains("<img"));
assert!(html.contains("photo.jpg"));
assert!(!html.contains("<video"));
assert!(!html.contains("<audio"));
assert!(!html.contains("pdf-embed"));
}
#[tokio::test]
async fn test_multiple_media_types_in_document() {
let md = r#"
# My Media




"#;
let html = render_markdown(md).await;
assert!(html.contains("<video"));
assert!(html.contains("<audio"));
assert!(html.contains("pdf-embed"));
assert!(html.contains("<img"));
}
#[tokio::test]
async fn test_vid_shortcode() {
let md = r#"{{ vid(path="test/video.mp4") }}"#;
let html = render_markdown(md).await;
println!("Output HTML: {}", html);
assert!(html.contains("<video"), "Should contain video element");
assert!(
html.contains("/videos/test/video.mp4"),
"Should contain video path"
);
}
#[tokio::test]
async fn test_vid_shortcode_with_spaces() {
let md = r#"{{ vid(path="Eric Jones/Eric Jones - Metal 3.mp4")}}"#;
let html = render_markdown(md).await;
println!("Output HTML: {}", html);
assert!(html.contains("<video"), "Should contain video element");
assert!(
html.contains("/videos/Eric%20Jones"),
"Should contain URL-encoded path"
);
}
#[tokio::test]
async fn test_link_transformation_regular_markdown() {
let md = "[Other Doc](other.md)";
let html = render_markdown_with_config(md, false, HashSet::new()).await;
assert!(
html.contains(r#"href="../other/""#),
"Regular markdown should transform other.md to ../other/. Got: {}",
html
);
}
#[tokio::test]
async fn test_link_transformation_index_file() {
let md = "[Other Doc](other.md)";
let html = render_markdown_with_config(md, true, HashSet::new()).await;
assert!(
html.contains(r#"href="other/""#),
"Index file should transform other.md to other/. Got: {}",
html
);
}
#[tokio::test]
async fn test_link_transformation_preserves_absolute_urls() {
let md = "[External](https://example.com)";
let html = render_markdown(md).await;
assert!(
html.contains(r#"href="https://example.com""#),
"Absolute URLs should remain unchanged"
);
}
#[tokio::test]
async fn test_link_transformation_with_anchor() {
let md = "[Section](other.md#section)";
let html = render_markdown_with_config(md, false, HashSet::new()).await;
assert!(
html.contains(r#"href="../other/#section""#),
"Links with anchors should transform correctly. Got: {}",
html
);
}
#[tokio::test]
async fn test_image_transformation_regular_markdown() {
let md = "";
let html = render_markdown_with_config(md, false, HashSet::new()).await;
assert!(
html.contains(r#"src="../images/photo.jpg""#),
"Image URLs should be transformed. Got: {}",
html
);
}
#[tokio::test]
async fn test_image_transformation_index_file() {
let md = "";
let html = render_markdown_with_config(md, true, HashSet::new()).await;
assert!(
html.contains(r#"src="images/photo.jpg""#),
"Index file image URLs shouldn't get ../. Got: {}",
html
);
}
#[tokio::test]
async fn test_video_embed_url_transformation() {
let md = "";
let html = render_markdown_with_config(md, false, HashSet::new()).await;
assert!(
html.contains("../video.mp4"),
"Video URLs should be transformed with ../. Got: {}",
html
);
}
#[tokio::test]
async fn test_video_embed_url_transformation_index_file() {
let md = "";
let html = render_markdown_with_config(md, true, HashSet::new()).await;
assert!(
!html.contains("../video.mp4"),
"Index file video URLs shouldn't get ../. Got: {}",
html
);
assert!(
html.contains("video.mp4"),
"Video URL should be present. Got: {}",
html
);
}
#[tokio::test]
async fn test_audio_embed_url_transformation() {
let md = "";
let html = render_markdown_with_config(md, false, HashSet::new()).await;
assert!(
html.contains("../episode.mp3"),
"Audio URLs should be transformed with ../. Got: {}",
html
);
}
#[tokio::test]
async fn test_pdf_embed_url_transformation() {
let md = "";
let html = render_markdown_with_config(md, false, HashSet::new()).await;
assert!(
html.contains("../report.pdf"),
"PDF URLs should be transformed with ../. Got: {}",
html
);
}
#[tokio::test]
async fn test_pdf_embed_url_transformation_index_file() {
let md = "";
let html = render_markdown_with_config(md, true, HashSet::new()).await;
assert!(
!html.contains("../report.pdf"),
"Index file PDF URLs shouldn't get ../. Got: {}",
html
);
assert!(
html.contains("report.pdf"),
"PDF URL should be present. Got: {}",
html
);
}
#[tokio::test]
async fn test_media_embed_peer_file_transformation() {
let md = "";
let html = render_markdown_with_config(md, false, HashSet::new()).await;
assert!(
html.contains("../peer-video.mp4"),
"Peer file video should get ../ prefix. Got: {}",
html
);
}
#[tokio::test]
async fn test_media_embed_explicit_relative_path() {
let md = "";
let html = render_markdown_with_config(md, false, HashSet::new()).await;
assert!(
html.contains("../peer-video.mp4"),
"./peer-video.mp4 should transform to ../peer-video.mp4. Got: {}",
html
);
}
#[tokio::test]
async fn test_section_attrs_with_id() {
let md = "First section\n\n--- {#intro}\n\nSecond section";
let html = render_markdown(md).await;
assert!(
html.contains(r#"<section id="intro">"#),
"Section should have id='intro'. Got: {}",
html
);
}
#[tokio::test]
async fn test_section_attrs_with_class() {
let md = "First section\n\n--- {.highlight}\n\nSecond section";
let html = render_markdown(md).await;
assert!(
html.contains(r#"<section class="highlight">"#),
"Section should have class='highlight'. Got: {}",
html
);
}
#[tokio::test]
async fn test_section_attrs_with_multiple_classes() {
let md = "First section\n\n--- {.slide .center}\n\nSecond section";
let html = render_markdown(md).await;
assert!(
html.contains(r#"<section class="slide center">"#),
"Section should have class='slide center'. Got: {}",
html
);
}
#[tokio::test]
async fn test_section_attrs_with_data_attributes() {
let md = "First section\n\n--- {data-transition=\"slide\"}\n\nSecond section";
let html = render_markdown(md).await;
assert!(
html.contains(r#"data-transition="slide""#),
"Section should have data-transition='slide'. Got: {}",
html
);
}
#[tokio::test]
async fn test_section_attrs_mixed() {
let md = "First section\n\n--- {#main .highlight data-bg=\"blue\"}\n\nSecond section";
let html = render_markdown(md).await;
assert!(
html.contains(r#"id="main""#),
"Section should have id='main'. Got: {}",
html
);
assert!(
html.contains(r#"class="highlight""#),
"Section should have class='highlight'. Got: {}",
html
);
assert!(
html.contains(r#"data-bg="blue""#),
"Section should have data-bg='blue'. Got: {}",
html
);
}
#[tokio::test]
async fn test_section_attrs_multiple_rules() {
let md = "Section 0\n\n--- {#one}\n\nSection 1\n\n--- {#two}\n\nSection 2";
let html = render_markdown(md).await;
assert!(
html.contains(r#"<section id="one">"#),
"First rule section should have id='one'. Got: {}",
html
);
assert!(
html.contains(r#"<section id="two">"#),
"Second rule section should have id='two'. Got: {}",
html
);
}
#[tokio::test]
async fn test_plain_rule_still_works() {
let md = "First section\n\n---\n\nSecond section";
let html = render_markdown(md).await;
let section_count = html.matches("<section>").count();
assert!(
section_count >= 1,
"Plain rule should create sections. Got: {}",
html
);
assert!(
html.contains("<hr />"),
"Should contain <hr /> divider. Got: {}",
html
);
}
#[tokio::test]
async fn test_em_dash_with_non_attrs_text() {
let md = "Some text\n\n--- not attrs\n\nMore text";
let html = render_markdown(md).await;
assert!(
html.contains("—"),
"Em dash should be preserved. Got: {}",
html
);
}
#[tokio::test]
async fn test_section_empty_attrs() {
let md = "First section\n\n--- {}\n\nSecond section";
let html = render_markdown(md).await;
assert!(
html.contains("<section>"),
"Empty attrs should create plain section. Got: {}",
html
);
assert!(
html.contains("<hr />"),
"Should contain <hr /> divider. Got: {}",
html
);
}
#[tokio::test]
async fn test_section_attrs_with_whitespace() {
let md = "First section\n\n--- { #intro .highlight }\n\nSecond section";
let html = render_markdown(md).await;
assert!(
html.contains(r#"id="intro""#),
"Whitespace should not affect ID parsing. Got: {}",
html
);
assert!(
html.contains(r#"class="highlight""#),
"Whitespace should not affect class parsing. Got: {}",
html
);
}
#[tokio::test]
async fn test_section_attrs_curly_quotes() {
let md = "First section\n\n--- {data-x=\u{201C}value\u{201D}}\n\nSecond section";
let html = render_markdown(md).await;
assert!(
html.contains(r#"data-x="value""#),
"Curly quotes should be normalized. Got: {}",
html
);
}
#[tokio::test]
async fn test_section_attrs_html_escaping() {
let md = "First section\n\n--- {data-val=\"a & b\"}\n\nSecond section";
let html = render_markdown(md).await;
assert!(
html.contains("&"),
"HTML special chars should be escaped. Got: {}",
html
);
assert!(
html.contains(r#"data-val="a & b""#),
"Value should have escaped &. Got: {}",
html
);
}
const DEFAULT_MARKERS: &[&str] = &["TK", "TODO", "FIXME", "XXX"];
const INCOMPLETE_SPAN_PREFIX: &str = "<span class=\"mbr-incomplete\"";
fn incomplete_span_count(html: &str) -> usize {
html.matches(INCOMPLETE_SPAN_PREFIX).count()
}
fn marker_anchor_lines(html: &str) -> Vec<u32> {
const ATTR: &str = "id=\"mbr-marker-";
html.match_indices(ATTR)
.map(|(at, _)| {
let rest = &html[at + ATTR.len()..];
let end = rest.find('"').expect("unterminated attribute");
rest[..end].parse().expect("numeric line")
})
.collect()
}
fn without_marker_ids(html: &str) -> String {
const ATTR: &str = " id=\"mbr-marker-";
let mut out = String::with_capacity(html.len());
let mut rest = html;
while let Some(at) = rest.find(ATTR) {
out.push_str(&rest[..at]);
let tail = &rest[at + ATTR.len()..];
let close = tail.find('"').expect("unterminated marker id");
rest = &tail[close + 1..];
}
out.push_str(rest);
out
}
#[tokio::test]
async fn test_incomplete_paragraph() {
let html = render_markdown_marked("TK rewrite this paragraph.", DEFAULT_MARKERS).await;
assert!(
without_marker_ids(&html).contains(r#"<p><span class="mbr-incomplete">"#),
"Paragraph should have span as first child. Got: {html}"
);
assert!(html.contains("TK rewrite"), "TK text preserved: {html}");
assert_eq!(marker_anchor_lines(&html), vec![1], "{html}");
}
#[tokio::test]
async fn test_incomplete_heading() {
let html = render_markdown_marked("## TODO finish this", DEFAULT_MARKERS).await;
assert!(
html.contains(INCOMPLETE_SPAN_PREFIX),
"Span should be present in heading. Got: {html}"
);
assert!(html.contains("<h2"), "h2 element present: {html}");
let h2_start = html.find("<h2").unwrap();
let span_start = html.find(INCOMPLETE_SPAN_PREFIX).unwrap();
assert!(span_start > h2_start, "span should be inside h2: {html}");
assert_eq!(marker_anchor_lines(&html), vec![1], "{html}");
}
#[tokio::test]
async fn test_incomplete_tight_list_item() {
let html = render_markdown_marked("- TK item one\n- normal item", DEFAULT_MARKERS).await;
assert!(
without_marker_ids(&html).contains(r#"<li><span class="mbr-incomplete">"#),
"Span should follow <li> for tight list: {html}"
);
assert_eq!(
incomplete_span_count(&html),
1,
"Only one span expected: {html}"
);
assert_eq!(marker_anchor_lines(&html), vec![1], "{html}");
}
#[tokio::test]
async fn test_incomplete_loose_list_item() {
let md = "- TK draft this\n\n- finished item\n";
let html = render_markdown_marked(md, DEFAULT_MARKERS).await;
let bare = without_marker_ids(&html);
assert!(
bare.contains(r#"<p><span class="mbr-incomplete">"#),
"Span should wrap inner <p> in loose list: {html}"
);
assert!(
!bare.contains(r#"<li><span class="mbr-incomplete">"#),
"Loose-list <li> should not have direct span child: {html}"
);
assert_eq!(marker_anchor_lines(&html), vec![1], "{html}");
}
#[tokio::test]
async fn test_incomplete_table_cell() {
let md = "| H |\n|---|\n| TK cell |\n";
let html = render_markdown_marked(md, DEFAULT_MARKERS).await;
assert!(
without_marker_ids(&html).contains(r#"<td><span class="mbr-incomplete">"#),
"Span should follow <td> for incomplete cell: {html}"
);
assert_eq!(marker_anchor_lines(&html), vec![3], "{html}");
}
#[tokio::test]
async fn test_remark_hint_tip() {
let html = render_markdown("!> tip").await;
assert!(
html.contains(r#"<blockquote class="markdown-alert-tip">"#),
"Expected tip alert blockquote: {html}"
);
assert!(
html.contains("<p>tip</p>"),
"Marker should be stripped: {html}"
);
assert!(!html.contains("!>"), "Escaped marker leaked: {html}");
assert!(!html.contains("!>"), "Raw marker leaked: {html}");
}
#[tokio::test]
async fn test_remark_hint_warning() {
let html = render_markdown("?> warn").await;
assert!(
html.contains(r#"<blockquote class="markdown-alert-warning">"#),
"Expected warning alert blockquote: {html}"
);
assert!(
html.contains("<p>warn</p>"),
"Marker should be stripped: {html}"
);
}
#[tokio::test]
async fn test_remark_hint_caution() {
let html = render_markdown("x> caution").await;
assert!(
html.contains(r#"<blockquote class="markdown-alert-caution">"#),
"Expected caution alert blockquote: {html}"
);
assert!(
html.contains("<p>caution</p>"),
"Marker should be stripped: {html}"
);
}
#[tokio::test]
async fn test_remark_hint_multiline() {
let html = render_markdown("!> line one\nline two").await;
assert!(
html.contains(r#"<blockquote class="markdown-alert-tip">"#),
"Expected tip alert blockquote: {html}"
);
assert!(html.contains("line one"), "First line retained: {html}");
assert!(html.contains("line two"), "Second line retained: {html}");
assert!(!html.contains("!>"), "Escaped marker leaked: {html}");
assert!(!html.contains("!>"), "Raw marker leaked: {html}");
}
#[tokio::test]
async fn test_remark_hint_requires_trailing_space() {
let html = render_markdown("!>no-space").await;
assert!(
!html.contains("markdown-alert"),
"Should not be converted without trailing space: {html}"
);
}
#[tokio::test]
async fn test_remark_hint_only_at_paragraph_start() {
let html = render_markdown("text !> more").await;
assert!(
!html.contains("markdown-alert"),
"Mid-paragraph marker should not be converted: {html}"
);
}
#[tokio::test]
async fn test_remark_hint_ignored_in_code_block() {
let html = render_markdown("```\n!> foo\n```").await;
assert!(
!html.contains("markdown-alert"),
"Code block content should not be converted: {html}"
);
assert!(
html.contains("!> foo") || html.contains("!> foo"),
"Code content should render verbatim: {html}"
);
}
#[tokio::test]
async fn test_native_github_alert_still_works() {
let html = render_markdown("> [!TIP]\n> hello").await;
assert!(
html.contains(r#"<blockquote class="markdown-alert-tip">"#),
"Native GitHub alert should still render: {html}"
);
assert!(html.contains("hello"), "Alert content retained: {html}");
}
#[tokio::test]
async fn test_incomplete_blockquote_paragraph() {
let html = render_markdown_marked("> TK quote me", DEFAULT_MARKERS).await;
assert!(
without_marker_ids(&html).contains(r#"<p><span class="mbr-incomplete">"#),
"Inner <p> should carry the span, not <blockquote>: {html}"
);
assert!(
!html.contains(r#"<blockquote><span"#),
"Blockquote should not be span-wrapped: {html}"
);
}
#[tokio::test]
async fn test_incomplete_with_strong_emphasis() {
let html = render_markdown_marked("**TK** finish later", DEFAULT_MARKERS).await;
assert!(
without_marker_ids(&html)
.contains(r#"<p><span class="mbr-incomplete"><strong>TK</strong>"#),
"Span should wrap <strong>TK</strong>: {html}"
);
assert_eq!(marker_anchor_lines(&html), vec![1], "{html}");
}
#[tokio::test]
async fn block_initial_marker_inside_strong_is_not_wrapped_twice() {
let html = render_markdown_marked("**TK** and later TK", DEFAULT_MARKERS).await;
assert_eq!(
incomplete_span_count(&html),
2,
"block wrap plus one inline wrap, not three: {html}"
);
assert!(
without_marker_ids(&html)
.contains(r#"<p><span class="mbr-incomplete"><strong>TK</strong>"#),
"the block wrap must still start at <strong>: {html}"
);
}
#[tokio::test]
async fn test_incomplete_with_link() {
let html =
render_markdown_marked("[TK](https://example.com) check this", DEFAULT_MARKERS).await;
assert!(
without_marker_ids(&html).contains(r#"<p><span class="mbr-incomplete"><a "#),
"Span should wrap the link: {html}"
);
}
#[tokio::test]
async fn test_incomplete_negative_tomato() {
let html = render_markdown_marked("Tomato is red.", DEFAULT_MARKERS).await;
assert!(
!html.contains("mbr-incomplete"),
"'Tomato' should not match: {html}"
);
}
#[tokio::test]
async fn test_incomplete_negative_lowercase() {
let html = render_markdown_marked("Tk lowercase ignored.", DEFAULT_MARKERS).await;
assert!(
!html.contains("mbr-incomplete"),
"Mixed case 'Tk' should not match: {html}"
);
let html2 = render_markdown_marked("todo lowercase.", DEFAULT_MARKERS).await;
assert!(
!html2.contains("mbr-incomplete"),
"lowercase 'todo' should not match: {html2}"
);
}
#[tokio::test]
async fn test_incomplete_negative_word_boundary() {
let html = render_markdown_marked("TKTK shouldn't match.", DEFAULT_MARKERS).await;
assert!(
!html.contains("mbr-incomplete"),
"TKTK should not match: {html}"
);
let html2 = render_markdown_marked("TODOs are plural.", DEFAULT_MARKERS).await;
assert!(
!html2.contains("mbr-incomplete"),
"'TODOs' should not match: {html2}"
);
}
#[tokio::test]
async fn incomplete_mid_paragraph_highlights_the_marker_word() {
let html =
render_markdown_marked("This paragraph mentions TK in the middle.", DEFAULT_MARKERS)
.await;
assert!(
without_marker_ids(&html).contains(
r#"This paragraph mentions <span class="mbr-incomplete">TK</span> in the middle."#
),
"only the marker word should be wrapped: {html}"
);
assert!(
!without_marker_ids(&html).contains(r#"<p><span class="mbr-incomplete">"#),
"the block itself must not be wrapped: {html}"
);
assert_eq!(marker_anchor_lines(&html), vec![1], "{html}");
}
#[tokio::test]
async fn test_incomplete_negative_code_block() {
let md = "```\nTK code lines\n```\n";
let html = render_markdown_marked(md, DEFAULT_MARKERS).await;
assert!(
!html.contains("mbr-incomplete"),
"TK in code block should not match: {html}"
);
}
#[tokio::test]
async fn marker_in_a_code_block_inside_a_list_item_is_not_highlighted() {
let fenced = "- ```\n TK not a task\n ```\n";
let html = render_markdown_marked(fenced, DEFAULT_MARKERS).await;
assert!(
!html.contains("mbr-incomplete"),
"fenced code in a list item must not be highlighted: {html}"
);
let indented = "- TK not a task\n";
let html = render_markdown_marked(indented, DEFAULT_MARKERS).await;
assert!(
!html.contains("mbr-incomplete"),
"indented code in a list item must not be highlighted: {html}"
);
}
#[tokio::test]
async fn test_incomplete_negative_frontmatter() {
let md = "---\ntitle: TK rename later\n---\n\nNormal paragraph.";
let html = render_markdown_marked(md, DEFAULT_MARKERS).await;
assert!(
!html.contains("mbr-incomplete"),
"TK in frontmatter should not match: {html}"
);
}
#[tokio::test]
async fn test_incomplete_disabled_no_span() {
let html = render_markdown("TK should not be highlighted.").await;
assert!(
!html.contains("mbr-incomplete"),
"Disabled flag suppresses span: {html}"
);
}
#[tokio::test]
async fn test_incomplete_custom_markers() {
let html = render_markdown_marked("NOTE this draft.", &["NOTE"]).await;
assert!(
without_marker_ids(&html).contains(r#"<p><span class="mbr-incomplete">"#),
"Custom marker NOTE should match: {html}"
);
let html2 = render_markdown_marked("TK ignored under custom list.", &["NOTE"]).await;
assert!(
!html2.contains("mbr-incomplete"),
"TK should not match when only NOTE configured: {html2}"
);
}
#[tokio::test]
async fn test_incomplete_empty_markers_no_op() {
let html = render_markdown_marked("TK still here.", &[]).await;
assert!(
!html.contains("mbr-incomplete"),
"Empty marker list should not inject spans: {html}"
);
}
#[tokio::test]
async fn marker_embedded_in_prose_is_highlighted() {
let html =
render_markdown_marked("The market fell 10% (source: TK).", DEFAULT_MARKERS).await;
assert_eq!(incomplete_span_count(&html), 1, "{html}");
assert_eq!(marker_anchor_lines(&html), vec![1], "{html}");
}
#[tokio::test]
async fn several_markers_in_one_run_each_get_a_span() {
let html =
render_markdown_marked("Fix TODO then FIXME then XXX later.", DEFAULT_MARKERS).await;
assert_eq!(
incomplete_span_count(&html),
3,
"one span per occurrence: {html}"
);
}
#[tokio::test]
async fn text_before_and_after_a_marker_survives_intact() {
let html =
render_markdown_marked("The market fell 10% (source: TK).", DEFAULT_MARKERS).await;
assert!(
html.contains(concat!(
r#"<p>The market fell 10% (source: "#,
r#"<span class="mbr-incomplete" id="mbr-marker-1">TK</span>).</p>"#
)),
"{html}"
);
}
#[tokio::test]
async fn smart_punctuation_does_not_shift_the_anchor_line() {
let md = concat!(
"An em -- dash and \"quotes\" on line one.\n",
"\n",
"More -- dashes and \"quotes\" here.\n",
"\n",
"Now a TK appears -- after more punctuation.\n",
);
let html = render_markdown_marked(md, DEFAULT_MARKERS).await;
assert!(
html.contains('\u{2013}'),
"smart punctuation must actually have run: {html}"
);
assert_eq!(marker_anchor_lines(&html), vec![5], "{html}");
}
#[tokio::test]
async fn marker_in_a_soft_wrapped_paragraph_gets_its_own_line() {
let html = render_markdown_marked(
"First line of prose.\nSecond line has TK here.",
DEFAULT_MARKERS,
)
.await;
assert_eq!(marker_anchor_lines(&html), vec![2], "{html}");
assert!(
!without_marker_ids(&html).contains(r#"<p><span class="mbr-incomplete">"#),
"a mid-paragraph marker must not wash the block: {html}"
);
}
#[tokio::test]
async fn marker_after_a_hard_break_gets_its_own_line() {
let html = render_markdown_marked("First line.\\\nTK second.", DEFAULT_MARKERS).await;
assert_eq!(marker_anchor_lines(&html), vec![2], "{html}");
assert!(html.contains("<br />"), "hard break expected: {html}");
}
#[tokio::test]
async fn two_markers_on_one_line_share_no_id() {
let html = render_markdown_marked("See TK here and TK there.", DEFAULT_MARKERS).await;
assert_eq!(incomplete_span_count(&html), 2, "{html}");
assert_eq!(marker_anchor_lines(&html), vec![1], "{html}");
}
#[tokio::test]
async fn two_table_cells_on_one_line_share_no_id() {
let md = "| A | B |\n|---|---|\n| TK a | TK b |\n";
let html = render_markdown_marked(md, DEFAULT_MARKERS).await;
assert_eq!(incomplete_span_count(&html), 2, "{html}");
assert_eq!(marker_anchor_lines(&html), vec![3], "{html}");
}
#[tokio::test]
async fn marker_in_inline_code_is_not_highlighted() {
let html = render_markdown_marked("Some `TODO` here.", DEFAULT_MARKERS).await;
assert!(!html.contains("mbr-incomplete"), "{html}");
assert!(html.contains("<code>TODO</code>"), "{html}");
}
#[tokio::test]
async fn marker_in_a_link_destination_is_not_highlighted() {
let html = render_markdown_marked(
r#"See [the notes](https://example.com/TODO-list "TODO later") for context."#,
DEFAULT_MARKERS,
)
.await;
assert!(!html.contains("mbr-incomplete"), "{html}");
assert!(html.contains("TODO-list"), "destination preserved: {html}");
}
#[tokio::test]
async fn marker_in_image_alt_text_is_not_highlighted() {
let html = render_markdown_marked(" and TK", DEFAULT_MARKERS).await;
assert!(html.contains(r#"alt="TK""#), "alt text intact: {html}");
assert_eq!(
incomplete_span_count(&html),
1,
"only the marker outside the image is wrapped: {html}"
);
assert_eq!(marker_anchor_lines(&html), vec![1], "{html}");
}
#[tokio::test]
async fn rule_attrs_paragraph_does_not_orphan_a_text_line_record() {
let md = "Intro prose.\n\n--- {#intro}\n\nTK draft this section.\n";
let html = render_markdown_marked(md, DEFAULT_MARKERS).await;
assert!(
html.contains(r#"id="intro""#),
"the rule attrs must still be applied: {html}"
);
assert_eq!(marker_anchor_lines(&html), vec![5], "{html}");
}
#[tokio::test]
async fn remark_hint_paragraph_carries_its_marker_line() {
let html = render_markdown_marked("Intro.\n\n!> TK check this\n", DEFAULT_MARKERS).await;
assert!(html.contains("markdown-alert-tip"), "{html}");
assert_eq!(marker_anchor_lines(&html), vec![3], "{html}");
}
#[tokio::test]
async fn extended_task_marker_carries_its_marker_line() {
let html = render_markdown_marked("Intro.\n\n- [-] TK abandoned\n", DEFAULT_MARKERS).await;
assert!(html.contains(r#"data-mbr-task-line="3""#), "{html}");
assert_eq!(marker_anchor_lines(&html), vec![3], "{html}");
}
#[tokio::test]
async fn crlf_line_endings_do_not_shift_marker_anchors() {
let html =
render_markdown_marked("TK one\r\n\r\nprose\r\n\r\nTK two\r\n", DEFAULT_MARKERS).await;
assert_eq!(marker_anchor_lines(&html), vec![1, 5], "{html}");
}
#[tokio::test]
async fn frontmatter_does_not_shift_marker_anchors() {
let md = "---\ntitle: TK rename later\n---\n\nTK here.\n";
let html = render_markdown_marked(md, DEFAULT_MARKERS).await;
assert_eq!(
incomplete_span_count(&html),
1,
"the frontmatter marker is not highlighted: {html}"
);
assert_eq!(marker_anchor_lines(&html), vec![5], "{html}");
}
#[tokio::test]
async fn heading_toc_text_has_no_marker_markup() {
let result = render_result_marked("# TODO write the intro\n", DEFAULT_MARKERS).await;
assert_eq!(result.headings[0].text, "TODO write the intro");
assert!(
result.html.contains(INCOMPLETE_SPAN_PREFIX),
"the heading itself is still highlighted: {}",
result.html
);
}
#[tokio::test]
async fn render_sync_and_render_agree_on_marker_anchors() {
let md = concat!(
"---\ntitle: T\n---\n\n",
"# TODO heading\n\n",
"Prose with a TK in the middle and another TK after it.\n",
"A soft-wrapped TK line.\n\n",
"- [ ] TODO: a task\n",
"- plain item\n\n",
"```\nTK in code\n```\n\n",
"| A | B |\n|---|---|\n| TK a | TK b |\n",
);
let mut file = NamedTempFile::new().unwrap();
file.write_all(md.as_bytes()).unwrap();
let path = file.path().to_path_buf();
let root = path.parent().unwrap().to_path_buf();
let config = LinkTransformConfig {
markdown_extensions: vec!["md".to_string()],
index_file: "index.md".to_string(),
is_index_file: false,
url_depth: None,
current_page_url: String::new(),
markdown_page_probe: None,
};
let markers: Vec<String> = DEFAULT_MARKERS.iter().map(|m| m.to_string()).collect();
let async_html = render(
path.clone(),
&root,
0,
config.clone(),
false,
false,
HashSet::new(),
true,
&markers,
None,
)
.await
.unwrap()
.html;
let sync_html = render_sync(
path,
&root,
0,
config,
None,
false,
false,
HashSet::new(),
true,
&markers,
None,
)
.unwrap()
.html;
assert_eq!(async_html, sync_html);
assert!(!marker_anchor_lines(&async_html).is_empty(), "{async_html}");
}
#[test]
fn process_all_events_maps_one_event_to_one_event() {
let md = concat!(
"---\ntitle: T\n---\n\n",
"# Heading\n\n",
"Prose with a [link](other.md) and an image .\n\n",
"https://youtu.be/dQw4w9WgXcQ\n\n",
"{{ vid(path=\"clip.mp4\") }}\n\n",
"- [ ] a task @due(2026-01-01)\n",
"- item with `code`\n\n",
"```rust\nfn main() {}\n```\n\n",
"| a | b |\n|---|---|\n| c | d |\n\n",
"> quote\n\n",
"--- {#id .cls}\n\n",
"!> hint\n",
);
let mut text_lines = TextLines::recording();
let (events, _, _) = collect_events_and_headings(md, TaskMarkup::Render, &mut text_lines);
let expected = events.len();
let config = LinkTransformConfig {
markdown_extensions: vec!["md".to_string()],
index_file: "index.md".to_string(),
is_index_file: false,
url_depth: None,
current_page_url: String::new(),
markdown_page_probe: None,
};
let (processed, _state) = process_all_events(
events,
Path::new("/tmp"),
Path::new("/tmp/note.md"),
config,
HashMap::new(),
false,
false,
HashSet::new(),
None,
);
assert_eq!(processed.len(), expected);
}
fn make_sources(sources: &[&str]) -> HashSet<String> {
sources.iter().map(|s| s.to_string()).collect()
}
#[tokio::test]
async fn test_wikilink_transformation() {
let sources = make_sources(&["tags"]);
let md = "Check out [[Tags:rust]] for more info.";
let html = render_markdown_with_tags(md, sources).await;
assert!(
html.contains(r#"href="/tags/rust/""#),
"Wikilink should transform to tag URL. Got: {}",
html
);
assert!(
html.contains(">rust<"),
"Link text should be the tag value. Got: {}",
html
);
}
#[tokio::test]
async fn test_wikilink_with_spaces() {
let sources = make_sources(&["performers"]);
let md = "Watch [[performers:Joshua Jay]] perform!";
let html = render_markdown_with_tags(md, sources).await;
assert!(
html.contains(r#"href="/performers/joshua_jay/""#),
"Wikilink with spaces should normalize URL. Got: {}",
html
);
assert!(
html.contains(">Joshua Jay<"),
"Link text should preserve original case. Got: {}",
html
);
}
#[tokio::test]
async fn test_wikilink_unknown_source_becomes_native_wikilink() {
let sources = make_sources(&["tags"]);
let md = "See [[category:books]] for more.";
let html = render_markdown_with_tags(md, sources).await;
assert!(
html.contains("<a"),
"Wikilink should become a link via pulldown-cmark. Got: {}",
html
);
assert!(
html.contains("category:books"),
"Link should reference the wikilink content. Got: {}",
html
);
}
#[tokio::test]
async fn test_markdown_tag_link() {
let sources = make_sources(&["tags"]);
let md = "[Learn Rust](Tags:rust)";
let html = render_markdown_with_tags(md, sources).await;
assert!(
html.contains(r#"href="/tags/rust/""#),
"Tag link should transform to tag URL. Got: {}",
html
);
assert!(
html.contains(">Learn Rust<"),
"Link text should be preserved. Got: {}",
html
);
}
#[tokio::test]
async fn test_markdown_tag_link_normalized() {
let sources = make_sources(&["performers"]);
let md = "[Great performer](performers:joshua_jay)";
let html = render_markdown_with_tags(md, sources).await;
assert!(
html.contains(r#"href="/performers/joshua_jay/""#),
"Tag link should transform to tag URL. Got: {}",
html
);
}
#[tokio::test]
async fn test_url_scheme_not_treated_as_tag() {
let sources = make_sources(&["tags", "https"]); let md = "[Example](https://example.com)";
let html = render_markdown_with_tags(md, sources).await;
assert!(
html.contains(r#"href="https://example.com""#),
"URL schemes should not be treated as tag sources. Got: {}",
html
);
}
#[tokio::test]
async fn test_multiple_wikilinks() {
let sources = make_sources(&["tags"]);
let md = "Learn [[Tags:rust]] and [[Tags:python]] today!";
let html = render_markdown_with_tags(md, sources).await;
assert!(
html.contains(r#"href="/tags/rust/""#),
"First wikilink should work. Got: {}",
html
);
assert!(
html.contains(r#"href="/tags/python/""#),
"Second wikilink should work. Got: {}",
html
);
}
#[tokio::test]
async fn test_nested_tag_source() {
let sources = make_sources(&["taxonomy.tags"]);
let md = "See [[taxonomy.tags:rust]] for more.";
let html = render_markdown_with_tags(md, sources).await;
assert!(
html.contains(r#"href="/taxonomy.tags/rust/""#),
"Nested tag source should work. Got: {}",
html
);
}
#[tokio::test]
async fn test_no_tag_sources_uses_native_wikilinks() {
let sources = HashSet::new();
let md = "See [[Tags:rust]] for more.";
let html = render_markdown_with_tags(md, sources).await;
assert!(
html.contains("<a"),
"Wikilink should become a link via pulldown-cmark. Got: {}",
html
);
assert!(
html.contains("Tags:rust"),
"Link should reference the wikilink content. Got: {}",
html
);
}
#[tokio::test]
async fn test_plain_wikilink_works() {
let html = render_markdown("Check out [[MyPage]] for more.").await;
assert!(
html.contains("<a"),
"Plain wikilink should become a link. Got: {}",
html
);
assert!(
html.contains("MyPage"),
"Link should reference MyPage. Got: {}",
html
);
}
#[tokio::test]
async fn test_plain_wikilink_with_spaces() {
let html = render_markdown("See [[My Page]] here.").await;
assert!(
html.contains("<a"),
"Wikilink with spaces should become a link. Got: {}",
html
);
assert!(
html.contains("My Page"),
"Link should preserve the page name. Got: {}",
html
);
}
#[tokio::test]
async fn test_tag_and_plain_wikilinks_together() {
let sources = make_sources(&["tags"]);
let md = "See [[Tags:rust]] and also [[MyPage]] for info.";
let html = render_markdown_with_tags(md, sources).await;
assert!(
html.contains(r#"href="/tags/rust/""#),
"Tag wikilink should transform to /tags/rust/. Got: {}",
html
);
assert!(
html.contains("MyPage"),
"Plain wikilink should reference MyPage. Got: {}",
html
);
let link_count = html.matches("<a").count();
assert!(
link_count >= 2,
"Should have at least 2 links. Got {} in: {}",
link_count,
html
);
}
#[tokio::test]
async fn test_code_blocks_with_unsupported_language() {
let md = "```unknownlang\nsome code\n```";
let html = render_markdown(md).await;
assert!(
html.contains("<pre><code class=\"language-unknownlang\">"),
"Unsupported language should still get a language class. Got: {}",
html
);
assert!(html.contains("some code"));
}
#[tokio::test]
async fn test_code_blocks_mixed_supported_and_unsupported_languages() {
let md = concat!(
"```rust\nfn main() {}\n```\n\n",
"```garbage_lang_404\nfoo bar\n```\n\n",
"```python\nprint(1)\n```",
);
let html = render_markdown(md).await;
assert!(
html.contains("language-rust"),
"Rust block missing. Got: {}",
html
);
assert!(
html.contains("language-garbage_lang_404"),
"Unsupported block missing. Got: {}",
html
);
assert!(
html.contains("language-python"),
"Python block missing. Got: {}",
html
);
assert!(html.contains("fn main"));
assert!(html.contains("foo bar"));
assert!(html.contains("print(1)"));
}
#[test]
fn yaml_loader_yields_no_documents_for_comment_only_frontmatter() {
assert!(
YamlLoader::load_from_str("# tags: [draft]")
.expect("comment-only YAML parses")
.is_empty()
);
assert!(load_first_yaml_doc("# tags: [draft]").is_none());
}
#[test]
fn parse_survives_comment_only_frontmatter() {
let mut file = NamedTempFile::new().unwrap();
file.write_all(b"---\n# tags: [draft]\n---\n\nBody text.\n")
.unwrap();
let doc = parse(file.path()).expect("parse must not panic");
assert!(
doc.frontmatter.is_empty(),
"comment-only frontmatter yields no metadata, got: {:?}",
doc.frontmatter
);
}
#[test]
fn extract_metadata_survives_comment_only_frontmatter() {
let mut file = NamedTempFile::new().unwrap();
file.write_all(b"---\n# tags: [draft]\n---\n\nBody text.\n")
.unwrap();
let meta = extract_metadata_from_file(file.path()).expect("must not panic");
assert!(
meta.metadata.is_empty(),
"comment-only frontmatter yields no metadata, got: {:?}",
meta.metadata
);
assert!(meta.relationships.is_empty());
}
const BOM_DOC: &str = "\u{feff}---\ntitle: My Page\ntags:\n - alpha\n---\n\nBody text.\n";
#[tokio::test]
async fn bom_prefixed_frontmatter_still_renders_as_metadata() {
let result = render_result(BOM_DOC).await;
assert_eq!(
result.frontmatter.get("title"),
Some(&serde_json::Value::String("My Page".to_string())),
"BOM suppressed the metadata block"
);
assert!(result.frontmatter.contains_key("tags"));
assert!(
!result.html.contains(EM_DASH),
"frontmatter leaked into the body as an em-dash heading: {}",
result.html
);
}
#[test]
fn bom_prefixed_frontmatter_extracts_metadata() {
let mut file = NamedTempFile::new().unwrap();
file.write_all(BOM_DOC.as_bytes()).unwrap();
let meta = extract_metadata_from_file(file.path()).unwrap();
assert_eq!(
meta.metadata.get("title"),
Some(&serde_json::Value::String("My Page".to_string()))
);
assert!(meta.metadata.contains_key("tags"));
}
#[test]
fn bom_prefixed_frontmatter_parses() {
let mut file = NamedTempFile::new().unwrap();
file.write_all(BOM_DOC.as_bytes()).unwrap();
let doc = parse(file.path()).unwrap();
assert_eq!(
doc.frontmatter.get("title"),
Some(&serde_json::Value::String("My Page".to_string()))
);
}
#[test]
fn strip_bom_leaves_bom_less_input_alone() {
assert_eq!(strip_bom("# Heading"), "# Heading");
assert_eq!(strip_bom("\u{feff}# Heading"), "# Heading");
let mut owned = String::from("\u{feff}---\n");
strip_bom_in_place(&mut owned);
assert_eq!(owned, "---\n");
let mut untouched = String::from("plain");
strip_bom_in_place(&mut untouched);
assert_eq!(untouched, "plain");
}
#[tokio::test]
async fn vid_shortcode_inside_code_fence_is_not_expanded() {
let html = render_result("```\n{{ vid(path=\"demo.mp4\") }}\n```\n")
.await
.html;
assert!(
!html.contains("<video"),
"vid shortcode expanded inside a fence: {html}"
);
assert!(
html.contains("vid(path="),
"shortcode should render literally: {html}"
);
}
#[tokio::test]
async fn bare_url_inside_code_fence_is_not_embedded() {
let html = render_result("```\nhttps://example.com/in-code\n```\n")
.await
.html;
assert!(
!html.contains("<a href="),
"bare URL linkified inside a fence: {html}"
);
assert!(
html.contains("https://example.com/in-code"),
"URL should render literally: {html}"
);
}
#[tokio::test]
async fn canceled_checkbox_marker_inside_code_fence_is_not_transformed() {
let html = render_result("```\n[-] not a checkbox\n```\n").await.html;
assert!(
!html.contains("mbr-task-check"),
"checkbox transform fired inside a fence: {html}"
);
assert!(
html.contains("[-] not a checkbox"),
"line should render literally: {html}"
);
}
#[tokio::test]
async fn text_transforms_still_apply_outside_code_fences() {
let vid_html = render_result("{{ vid(path=\"demo.mp4\") }}").await.html;
assert!(
vid_html.contains("<video"),
"vid shortcode outside a fence must expand: {vid_html}"
);
let url_html = render_result("https://example.com/outside").await.html;
assert!(
url_html.contains("<a href=\"https://example.com/outside\""),
"bare URL outside a fence must still be linkified: {url_html}"
);
let todo_html = render_result("- [-] canceled task").await.html;
assert!(
todo_html.contains("mbr-task-check"),
"checkbox transform must still work outside a fence: {todo_html}"
);
}
#[test]
fn collect_bare_urls_skips_code_blocks() {
let (fenced, _, _) = collect_events_and_headings(
"```\nhttps://example.com/in-code\n```\n",
TaskMarkup::Skip,
&mut TextLines::disabled(),
);
assert!(
collect_bare_urls(&fenced).is_empty(),
"code-block URLs must not be queued for fetching"
);
let (prose, _, _) = collect_events_and_headings(
"https://example.com/outside\n",
TaskMarkup::Skip,
&mut TextLines::disabled(),
);
assert_eq!(
collect_bare_urls(&prose).len(),
1,
"prose URLs must still be queued"
);
}
#[tokio::test]
async fn local_embeds_render_at_timeout_zero_in_both_paths() {
let mut file = NamedTempFile::new().unwrap();
file.write_all(b"https://youtu.be/dQw4w9WgXcQ\n").unwrap();
let path = file.path().to_path_buf();
let root = path.parent().unwrap().to_path_buf();
let config = LinkTransformConfig {
markdown_extensions: vec!["md".to_string()],
index_file: "index.md".to_string(),
is_index_file: false,
url_depth: None,
current_page_url: String::new(),
markdown_page_probe: None,
};
let async_result = render_with_cache(
path.clone(),
&root,
0,
config.clone(),
None,
false,
false,
HashSet::new(),
false,
&[],
None,
)
.await
.unwrap();
let sync_result = render_sync(
path,
&root,
0,
config,
None,
false,
false,
HashSet::new(),
false,
&[],
None,
)
.unwrap();
assert!(
async_result.html.contains("youtube-embed"),
"async path dropped the no-network embed: {}",
async_result.html
);
assert_eq!(
async_result.html, sync_result.html,
"async and sync render paths must agree at oembed_timeout_ms = 0"
);
}
#[tokio::test]
async fn heading_text_and_anchor_include_inline_code() {
let result = render_result("## The `main` function\n").await;
assert_eq!(result.headings[0].text, "The main function");
assert_eq!(result.headings[0].id, "the-main-function");
}
#[test]
fn extract_first_h1_includes_inline_code() {
assert_eq!(
extract_first_h1("# The `main` function\n"),
Some("The main function".to_string())
);
}
#[tokio::test]
async fn heading_text_excludes_raw_inline_html() {
let result = render_result("## Press <kbd>Ctrl</kbd> now\n").await;
assert_eq!(result.headings[0].text, "Press Ctrl now");
assert_eq!(result.headings[0].id, "press-ctrl-now");
}
#[test]
fn slugify_lowercases_and_separates_words() {
assert_eq!(slugify("Hello World"), "hello-world");
assert_eq!(slugify("Meeting Notes"), "meeting-notes");
assert_eq!(slugify("Ünïcode Heading"), "ünïcode-heading");
assert_eq!(slugify("already-slugged"), "already-slugged");
}
#[test]
fn slugify_drops_punctuation() {
assert_eq!(slugify("Field Note!"), "field-note");
assert_eq!(slugify("Hello, World!"), "hello--world");
}
#[test]
fn slugify_returns_empty_for_nothing_to_keep() {
assert_eq!(slugify(""), "");
assert_eq!(slugify("!!!"), "");
}
#[test]
fn generate_anchor_id_basic_slugification() {
let mut anchor_ids = HashMap::new();
assert_eq!(
generate_anchor_id("Hello World", &mut anchor_ids),
"hello-world"
);
assert_eq!(
generate_anchor_id("Ünïcode Heading", &mut anchor_ids),
"ünïcode-heading"
);
assert_eq!(
generate_anchor_id("Hello, World!", &mut anchor_ids),
"hello--world"
);
}
#[test]
fn generate_anchor_id_never_collides() {
let mut anchor_ids = HashMap::new();
let headings = ["Step 1", "Step 1", "Step 1-2", "", "", "Ünïcode Ünïcode"];
let ids: Vec<String> = headings
.iter()
.map(|h| generate_anchor_id(h, &mut anchor_ids))
.collect();
assert_eq!(
ids.iter().collect::<HashSet<_>>().len(),
ids.len(),
"duplicate anchor ids: {ids:?}"
);
assert_eq!(ids[0], "step-1");
assert_eq!(ids[1], "step-1-2");
assert_eq!(ids[3], "heading");
assert_eq!(ids[4], "heading-2");
}
#[test]
fn cap_fetch_list_limits_and_is_deterministic() {
let urls: Vec<String> = (0..150)
.map(|i| format!("https://example.com/{i:03}"))
.collect();
let mut expected = urls.clone();
expected.sort_unstable();
expected.truncate(MAX_OEMBED_FETCHES_PER_DOC);
assert_eq!(
cap_fetch_list(urls.clone()).len(),
MAX_OEMBED_FETCHES_PER_DOC
);
assert_eq!(cap_fetch_list(urls.clone()), expected);
let reversed: Vec<String> = urls.into_iter().rev().collect();
assert_eq!(cap_fetch_list(reversed), expected);
}
#[test]
fn cap_fetch_list_leaves_small_lists_untouched() {
let urls = vec![
"https://b.example".to_string(),
"https://a.example".to_string(),
];
assert_eq!(
cap_fetch_list(urls.clone()),
urls,
"lists under the cap must be passed through unchanged"
);
}
#[tokio::test]
async fn prefetch_oembed_urls_resolves_local_embeds_without_network() {
let md = "https://youtu.be/aaaaaaaaaaa\n\nhttps://youtu.be/bbbbbbbbbbb\n\nhttps://youtu.be/ccccccccccc\n";
let (events, _, _) =
collect_events_and_headings(md, TaskMarkup::Skip, &mut TextLines::disabled());
let results = prefetch_oembed_urls(&events, 500, &None).await;
assert_eq!(results.len(), 3);
assert!(results.values().all(|info| info.embed_html.is_some()));
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn anchor_ids_are_always_unique(
headings in proptest::collection::vec(any::<String>(), 0..30)
) {
let mut anchor_ids = HashMap::new();
let ids: Vec<String> = headings
.iter()
.map(|h| generate_anchor_id(h, &mut anchor_ids))
.collect();
let unique: HashSet<&String> = ids.iter().collect();
prop_assert_eq!(unique.len(), ids.len());
}
}
const PROP_MARKERS: &[&str] = &["TK", "TODO", "FIXME", "XXX"];
fn line_fragment() -> impl Strategy<Value = &'static str> {
prop_oneof![
Just("TK"),
Just("TODO: fix this up"),
Just("plain prose with nothing in it"),
Just("some TK buried in the middle of a line"),
Just("two TK on one TK line"),
Just("- [ ] TK a task"),
Just("- [-] TODO abandoned"),
Just("# TODO heading"),
Just("> TK quoted"),
Just("| TK a | TK b |"),
Just("|---|---|"),
Just("```"),
Just(" and TK"),
Just("`TK` in code and TK outside"),
Just("[TK](https://example.com/TODO) trailing XXX"),
Just("--- {#sec}"),
Just("!> TK hint"),
Just("FIXME -- with \"smart\" punctuation"),
Just(""),
]
}
fn document() -> impl Strategy<Value = String> {
proptest::collection::vec(line_fragment(), 0..25).prop_map(|lines| {
let mut doc = lines.join("\n");
doc.push('\n');
doc
})
}
fn marked_events(md: &str) -> (Vec<Event<'_>>, Vec<Event<'_>>) {
let owned: Vec<String> = PROP_MARKERS.iter().map(|m| m.to_string()).collect();
let rule = MarkerRule::new(&owned).expect("a non-empty marker list compiles");
let mut text_lines = TextLines::recording();
let (events, _headings, _attrs) =
collect_events_and_headings(md, TaskMarkup::Render, &mut text_lines);
let marked = mark_incomplete_blocks(events.clone(), &rule, &text_lines);
(events, marked)
}
fn incomplete_opens(events: &[Event<'_>]) -> usize {
events
.iter()
.filter(|event| {
matches!(event, Event::Html(html) if html.starts_with("<span class=\"mbr-incomplete\""))
})
.count()
}
fn span_closes(events: &[Event<'_>]) -> usize {
events
.iter()
.filter(|event| matches!(event, Event::Html(html) if html.as_ref() == INCOMPLETE_SPAN_CLOSE))
.count()
}
proptest! {
#[test]
fn marker_spans_are_always_balanced(md in document()) {
let (before, after) = marked_events(&md);
let opened = incomplete_opens(&after);
let closed = span_closes(&after) - span_closes(&before);
prop_assert_eq!(opened, closed);
}
#[test]
fn no_duplicate_ids(md in document()) {
let owned: Vec<String> = PROP_MARKERS.iter().map(|m| m.to_string()).collect();
let rule = MarkerRule::new(&owned).expect("a non-empty marker list compiles");
let mut text_lines = TextLines::recording();
let (events, _headings, section_attrs) =
collect_events_and_headings(&md, TaskMarkup::Render, &mut text_lines);
let marked = mark_incomplete_blocks(events, &rule, &text_lines);
let mut html = String::new();
crate::html::push_html_mbr_with_attrs(&mut html, marked.into_iter(), section_attrs);
const ATTR: &str = "id=\"mbr-";
let ids: Vec<&str> = html
.match_indices(ATTR)
.map(|(at, _)| {
let rest = &html[at + ATTR.len()..];
&rest[..rest.find('"').expect("unterminated id")]
})
.collect();
let unique: HashSet<&&str> = ids.iter().collect();
prop_assert_eq!(unique.len(), ids.len(), "duplicate id in: {}", html);
}
}
}