use std::collections::{BTreeMap, HashMap};
use std::ops::Range;
use std::sync::Arc;
use pulldown_cmark::{CodeBlockKind, Event, MetadataBlockKind, Options, Parser, Tag, TagEnd};
use crate::Result;
use crate::frontmatter::{Frontmatter, FrontmatterFormat};
use crate::handler::{
BoxedHandler, BoxedInlineCodeHandler, BoxedLinkResolver, BoxedReqHandler, CodeBlockHandler,
CodeBlockOutput, DefaultReqHandler, InlineCodeHandler, RawCodeHandler, ReqHandler, html_escape,
};
use crate::headings::{Heading, slugify};
use crate::links::resolve_link;
use crate::reqs::{InlineCodeSpan, ReqDefinition, RuleId, SourceSpan, parse_req_marker};
#[derive(Debug)]
#[allow(dead_code)] enum ParseContext<'a> {
Metadata { kind: MetadataBlockKind },
Heading {
level: u8,
text: String,
start_offset: usize,
},
Paragraph {
text: String,
start_offset: usize,
events: Vec<(Event<'a>, Range<usize>)>,
},
BlockQuote {
start_offset: usize,
events: Vec<(Event<'a>, Range<usize>)>,
first_para_text: String,
first_para_done: bool,
},
CodeBlock {
full_language: String,
base_language: String,
code: String,
line: usize,
},
}
impl<'a> ParseContext<'a> {
fn is_metadata(&self) -> bool {
matches!(self, ParseContext::Metadata { .. })
}
fn is_blockquote(&self) -> bool {
matches!(self, ParseContext::BlockQuote { .. })
}
}
fn stack_contains<'a>(
stack: &[ParseContext<'a>],
predicate: impl Fn(&ParseContext<'a>) -> bool,
) -> bool {
stack.iter().any(predicate)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Paragraph {
pub line: usize,
pub offset: usize,
}
#[derive(Debug, Clone)]
pub enum DocElement {
Heading(Heading),
Req(ReqDefinition),
Paragraph(Paragraph),
}
#[derive(Default)]
pub struct RenderOptions {
pub source_path: Option<String>,
pub code_handlers: HashMap<String, BoxedHandler>,
pub default_handler: Option<BoxedHandler>,
pub req_handler: Option<BoxedReqHandler>,
pub inline_code_handler: Option<BoxedInlineCodeHandler>,
pub link_resolver: Option<BoxedLinkResolver>,
}
impl RenderOptions {
pub fn new() -> Self {
Self::default()
}
pub fn with_handler<H: CodeBlockHandler + 'static>(
mut self,
languages: &[&str],
handler: H,
) -> Self {
let handler = Arc::new(handler);
for language in languages {
self.code_handlers
.insert(language.to_string(), handler.clone());
}
self
}
pub fn with_default_handler<H: CodeBlockHandler + 'static>(mut self, handler: H) -> Self {
self.default_handler = Some(Arc::new(handler));
self
}
pub fn with_req_handler<H: ReqHandler + 'static>(mut self, handler: H) -> Self {
self.req_handler = Some(Arc::new(handler));
self
}
pub fn with_source_path(mut self, path: &str) -> Self {
self.source_path = Some(path.to_string());
self
}
pub fn with_inline_code_handler<H: InlineCodeHandler + 'static>(mut self, handler: H) -> Self {
self.inline_code_handler = Some(Arc::new(handler));
self
}
pub fn with_link_resolver<R: crate::handler::LinkResolver + 'static>(
mut self,
resolver: R,
) -> Self {
self.link_resolver = Some(Arc::new(resolver));
self
}
}
fn render_inline_code(code: &str, handler: Option<&BoxedInlineCodeHandler>) -> String {
if let Some(h) = handler
&& let Some(rendered) = h.render(code)
{
return rendered;
}
format!("<code>{}</code>", html_escape(code))
}
async fn resolve_link_with_resolver(
link: &str,
source_path: Option<&str>,
resolver: Option<&BoxedLinkResolver>,
) -> String {
if let Some(r) = resolver
&& let Some(resolved) = r.resolve(link, source_path).await
{
return resolved;
}
resolve_link(link, source_path)
}
#[derive(Debug, Clone)]
pub struct CodeSample {
pub line: usize,
pub language: String,
pub code: String,
}
#[derive(Debug, Clone)]
pub struct Document {
pub raw_metadata: Option<String>,
pub metadata_format: Option<FrontmatterFormat>,
pub frontmatter: Option<Frontmatter>,
pub html: String,
pub headings: Vec<Heading>,
pub reqs: Vec<ReqDefinition>,
pub code_samples: Vec<CodeSample>,
pub elements: Vec<DocElement>,
pub head_injections: Vec<String>,
pub inline_code_spans: Vec<InlineCodeSpan>,
}
fn offset_to_line(content: &str, offset: usize) -> usize {
content[..offset.min(content.len())].matches('\n').count() + 1
}
pub async fn render(markdown: &str, options: &RenderOptions) -> Result<Document> {
let parser_options = Options::ENABLE_TABLES
| Options::ENABLE_FOOTNOTES
| Options::ENABLE_STRIKETHROUGH
| Options::ENABLE_HEADING_ATTRIBUTES
| Options::ENABLE_YAML_STYLE_METADATA_BLOCKS
| Options::ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS;
let parser = Parser::new_ext(markdown, parser_options).into_offset_iter();
let mut headings: Vec<Heading> = Vec::new();
let mut reqs: Vec<ReqDefinition> = Vec::new();
let mut elements: Vec<DocElement> = Vec::new();
let mut code_samples: Vec<CodeSample> = Vec::new();
let mut inline_code_spans: Vec<InlineCodeSpan> = Vec::new();
let mut head_injection_map: BTreeMap<String, String> = BTreeMap::new();
let mut html = String::new();
let mut raw_metadata: Option<String> = None;
let mut metadata_format: Option<FrontmatterFormat> = None;
let mut heading_stack: Vec<(u8, String)> = Vec::new();
let mut seen_req_ids: std::collections::HashSet<RuleId> = std::collections::HashSet::new();
let mut seen_req_bases: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut context_stack: Vec<ParseContext<'_>> = Vec::new();
let default_req_handler: Arc<dyn ReqHandler> = Arc::new(DefaultReqHandler);
let req_handler = options.req_handler.as_ref().unwrap_or(&default_req_handler);
let default_code_handler: BoxedHandler = Arc::new(RawCodeHandler);
let is_inside_blockquote =
|stack: &[ParseContext<'_>]| stack_contains(stack, |c| c.is_blockquote());
for (event, range) in parser {
if let Event::Code(code) = &event {
inline_code_spans.push(InlineCodeSpan {
content: code.to_string(),
span: SourceSpan {
offset: range.start,
length: range.len(),
},
});
}
if is_inside_blockquote(&context_stack) {
match &event {
Event::Start(Tag::BlockQuote(_)) => {
context_stack.push(ParseContext::BlockQuote {
start_offset: range.start,
events: vec![(event, range)],
first_para_text: String::new(),
first_para_done: false,
});
continue;
}
Event::End(TagEnd::BlockQuote(_)) => {
if let Some(ParseContext::BlockQuote {
start_offset,
mut events,
first_para_text,
..
}) = context_stack.pop()
{
events.push((event, range.clone()));
let trimmed = first_para_text.trim();
if let Some((prefix, _, _)) = parse_req_leading_marker(trimmed) {
let marker = format!("{}[", prefix);
let marker_offset = markdown[start_offset..]
.find(&marker)
.map(|i| start_offset + i)
.unwrap_or(start_offset);
if let Some(req_result) = try_parse_blockquote_req(
trimmed,
markdown,
marker_offset,
range.end,
&mut seen_req_ids,
&mut seen_req_bases,
) {
match req_result {
Ok(mut req) => {
let content_html = render_blockquote_req_content(
&events,
options,
&default_code_handler,
)
.await?;
req.html = content_html.clone();
let start_html = req_handler.start(&req).await?;
let end_html = req_handler.end(&req).await?;
let req_html =
format!("{}{}{}", start_html, content_html, end_html);
if is_inside_blockquote(&context_stack) {
if let Some(ParseContext::BlockQuote {
events: parent_events,
..
}) = context_stack.last_mut()
{
parent_events
.push((Event::Html(req_html.into()), range));
}
} else {
html.push_str(&req_html);
}
reqs.push(req.clone());
elements.push(DocElement::Req(req));
continue;
}
Err(_) => {
}
}
}
}
if is_inside_blockquote(&context_stack) {
if let Some(ParseContext::BlockQuote {
events: parent_events,
..
}) = context_stack.last_mut()
{
parent_events.append(&mut events);
}
} else {
render_events_to_html(&mut html, &events, options, None).await;
}
}
continue;
}
Event::Start(Tag::Paragraph) => {
if let Some(ParseContext::BlockQuote { events, .. }) = context_stack.last_mut()
{
events.push((event, range));
}
continue;
}
Event::End(TagEnd::Paragraph) => {
if let Some(ParseContext::BlockQuote {
events,
first_para_done,
..
}) = context_stack.last_mut()
{
events.push((event, range));
*first_para_done = true;
}
continue;
}
Event::Text(text) => {
if let Some(ParseContext::BlockQuote {
events,
first_para_text,
first_para_done,
..
}) = context_stack.last_mut()
{
if !*first_para_done {
first_para_text.push_str(text);
}
events.push((event, range));
}
continue;
}
_ => {
if let Some(ParseContext::BlockQuote { events, .. }) = context_stack.last_mut()
{
events.push((event, range));
}
continue;
}
}
}
match &event {
Event::Start(Tag::BlockQuote(_)) => {
context_stack.push(ParseContext::BlockQuote {
start_offset: range.start,
events: vec![(event, range)],
first_para_text: String::new(),
first_para_done: false,
});
}
Event::Start(Tag::Heading { level, .. }) => {
context_stack.push(ParseContext::Heading {
level: *level as u8,
text: String::new(),
start_offset: range.start,
});
}
Event::End(TagEnd::Heading(level)) => {
let current_level = *level as u8;
if let Some(ParseContext::Heading {
text: heading_text,
start_offset,
..
}) = context_stack.pop()
{
let slug = slugify(&heading_text);
while heading_stack
.last()
.is_some_and(|(lvl, _)| *lvl >= current_level)
{
heading_stack.pop();
}
let id = if heading_stack.is_empty() {
slug.clone()
} else {
let mut id = String::new();
for (_, parent_slug) in &heading_stack {
id.push_str(parent_slug);
id.push_str("--");
}
id.push_str(&slug);
id
};
heading_stack.push((current_level, slug));
let line = offset_to_line(markdown, start_offset);
let heading = Heading {
title: heading_text.clone(),
id: id.clone(),
level: current_level,
line,
};
headings.push(heading.clone());
elements.push(DocElement::Heading(heading));
html.push_str(&format!(
"<h{} id=\"{}\">{}</h{}>",
current_level,
html_escape(&id),
html_escape(&heading_text),
current_level
));
}
}
Event::Start(Tag::Paragraph) => {
context_stack.push(ParseContext::Paragraph {
text: String::new(),
start_offset: range.start,
events: vec![(event, range)],
});
}
Event::End(TagEnd::Paragraph) => {
if let Some(ParseContext::Paragraph {
text: paragraph_text,
start_offset,
mut events,
}) = context_stack.pop()
{
events.push((event, range));
let trimmed = paragraph_text.trim();
if parse_req_leading_marker(trimmed).is_some()
&& let Some(req_result) = try_parse_paragraph_req(
trimmed,
markdown,
start_offset,
&mut seen_req_ids,
&mut seen_req_bases,
&events,
)
{
match req_result {
Ok(mut req) => {
let content_html =
render_paragraph_req_content(&events, options).await;
req.html = content_html.clone();
let start_html = req_handler.start(&req).await?;
let end_html = req_handler.end(&req).await?;
html.push_str(&start_html);
html.push_str(&content_html);
html.push_str(&end_html);
reqs.push(req.clone());
elements.push(DocElement::Req(req));
continue;
}
Err(_) => {
}
}
}
let line = offset_to_line(markdown, start_offset);
elements.push(DocElement::Paragraph(Paragraph {
line,
offset: start_offset,
}));
render_events_to_html(&mut html, &events, options, Some(SourceInfo { line }))
.await;
}
}
Event::Start(Tag::CodeBlock(kind)) => {
let full_language = match kind {
CodeBlockKind::Fenced(lang) => lang.split_whitespace().next().unwrap_or(""),
CodeBlockKind::Indented => "",
};
let base_language = full_language.split(',').next().unwrap_or(full_language);
let line = offset_to_line(markdown, range.start);
context_stack.push(ParseContext::CodeBlock {
full_language: full_language.to_string(),
base_language: base_language.to_string(),
code: String::new(),
line,
});
}
Event::End(TagEnd::CodeBlock) => {
if let Some(ParseContext::CodeBlock {
full_language,
base_language,
code,
line,
}) = context_stack.pop()
{
let handler = options
.code_handlers
.get(&base_language)
.or(options.default_handler.as_ref())
.unwrap_or(&default_code_handler);
let code_trimmed = code.trim_end_matches('\n');
let CodeBlockOutput {
html: rendered,
head_injections,
} = handler.render(&base_language, code_trimmed).await?;
html.push_str(&rendered);
for inj in head_injections {
head_injection_map.entry(inj.key).or_insert(inj.html);
}
code_samples.push(CodeSample {
line,
language: full_language,
code,
});
}
}
Event::Start(Tag::MetadataBlock(kind)) => {
metadata_format = Some(match kind {
MetadataBlockKind::YamlStyle => FrontmatterFormat::Yaml,
MetadataBlockKind::PlusesStyle => FrontmatterFormat::Toml,
});
context_stack.push(ParseContext::Metadata { kind: *kind });
}
Event::End(TagEnd::MetadataBlock(_)) => {
context_stack.pop();
}
Event::Text(text) => match context_stack.last_mut() {
Some(ParseContext::Heading { text: t, .. }) => {
t.push_str(text);
}
Some(ParseContext::Paragraph {
text: t, events, ..
}) => {
t.push_str(text);
events.push((event, range));
}
Some(ParseContext::CodeBlock { code, .. }) => {
code.push_str(text);
}
Some(ParseContext::Metadata { .. }) => {
raw_metadata = Some(text.to_string());
}
Some(ParseContext::BlockQuote { .. }) => {
unreachable!("BlockQuote text should be handled in blockquote branch");
}
None => {
html.push_str(&html_escape(text));
}
},
Event::Code(code) => match context_stack.last_mut() {
Some(ParseContext::Heading { text, .. }) => {
text.push_str(code);
}
Some(ParseContext::Paragraph { text, events, .. }) => {
text.push('`');
text.push_str(code);
text.push('`');
events.push((event, range));
}
_ => {
html.push_str(&render_inline_code(
code,
options.inline_code_handler.as_ref(),
));
}
},
Event::SoftBreak => {
if let Some(ParseContext::Paragraph { text, events, .. }) = context_stack.last_mut()
{
text.push(' ');
events.push((event, range));
} else {
html.push('\n');
}
}
Event::HardBreak => {
if let Some(ParseContext::Paragraph { text, events, .. }) = context_stack.last_mut()
{
text.push('\n');
events.push((event, range));
} else {
html.push_str("<br />\n");
}
}
Event::Start(Tag::Link {
dest_url, title, ..
}) => {
if let Some(ParseContext::Paragraph { events, .. }) = context_stack.last_mut() {
events.push((event, range));
} else if !stack_contains(&context_stack, |c| c.is_metadata()) {
let resolved = resolve_link_with_resolver(
dest_url,
options.source_path.as_deref(),
options.link_resolver.as_ref(),
)
.await;
let title_attr = if title.is_empty() {
String::new()
} else {
format!(" title=\"{}\"", html_escape(title))
};
html.push_str(&format!(
"<a href=\"{}\"{}>",
html_escape(&resolved),
title_attr
));
}
}
Event::End(TagEnd::Link) => {
if let Some(ParseContext::Paragraph { events, .. }) = context_stack.last_mut() {
events.push((event, range));
} else if !stack_contains(&context_stack, |c| c.is_metadata()) {
html.push_str("</a>");
}
}
_ => {
if let Some(ParseContext::Paragraph { events, .. }) = context_stack.last_mut() {
events.push((event, range));
} else if !stack_contains(&context_stack, |c| c.is_metadata()) {
pulldown_cmark::html::push_html(&mut html, std::iter::once(event.clone()));
}
}
}
}
let frontmatter = match (&raw_metadata, &metadata_format) {
(Some(raw), Some(FrontmatterFormat::Toml)) => facet_toml::from_str::<Frontmatter>(raw).ok(),
(Some(raw), Some(FrontmatterFormat::Yaml)) => facet_yaml::from_str::<Frontmatter>(raw).ok(),
_ => None,
};
Ok(Document {
raw_metadata,
metadata_format,
frontmatter,
html,
headings,
reqs,
code_samples,
elements,
head_injections: head_injection_map.into_values().collect(),
inline_code_spans,
})
}
async fn render_events_to_html(
html: &mut String,
events: &[(Event<'_>, Range<usize>)],
options: &RenderOptions,
source_info: Option<SourceInfo>,
) {
let mut i = 0;
while i < events.len() {
let (event, _range) = &events[i];
match event {
Event::Start(Tag::Paragraph) => {
let mut attrs = String::new();
if let Some(ref info) = source_info {
attrs.push_str(&format!(" data-source-line=\"{}\"", info.line));
if let Some(ref file) = options.source_path {
attrs.push_str(&format!(" data-source-file=\"{}\"", html_escape(file)));
}
}
html.push_str(&format!("<p{}>", attrs));
}
Event::End(TagEnd::Paragraph) => {
html.push_str("</p>\n");
}
Event::Start(Tag::Image {
dest_url, title, ..
}) => {
let mut alt_text = String::new();
i += 1;
while i < events.len() {
match &events[i].0 {
Event::End(TagEnd::Image) => break,
Event::Text(t) => alt_text.push_str(t),
Event::Code(c) => alt_text.push_str(c),
Event::SoftBreak | Event::HardBreak => alt_text.push(' '),
_ => {}
}
i += 1;
}
let title_attr = if title.is_empty() {
String::new()
} else {
format!(" title=\"{}\"", html_escape(title))
};
html.push_str(&format!(
"<img src=\"{}\" alt=\"{}\"{} />",
html_escape(dest_url),
html_escape(&alt_text),
title_attr
));
}
Event::End(TagEnd::Image) => {
}
Event::Start(Tag::Link {
dest_url, title, ..
}) => {
let resolved = resolve_link_with_resolver(
dest_url,
options.source_path.as_deref(),
options.link_resolver.as_ref(),
)
.await;
let title_attr = if title.is_empty() {
String::new()
} else {
format!(" title=\"{}\"", html_escape(title))
};
html.push_str(&format!(
"<a href=\"{}\"{}>",
html_escape(&resolved),
title_attr
));
}
Event::End(TagEnd::Link) => {
html.push_str("</a>");
}
Event::Code(code) => {
html.push_str(&render_inline_code(
code,
options.inline_code_handler.as_ref(),
));
}
_ => {
pulldown_cmark::html::push_html(html, std::iter::once(event.clone()));
}
}
i += 1;
}
}
struct SourceInfo {
line: usize,
}
fn req_marker_regex() -> &'static regex::Regex {
static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
RE.get_or_init(|| regex::Regex::new(r"^[a-z0-9]+\[[^\]]+\]\s*").unwrap())
}
fn strip_req_marker(text: &str) -> String {
req_marker_regex().replace(text, "").into_owned()
}
async fn render_paragraph_req_content(
events: &[(Event<'_>, Range<usize>)],
options: &RenderOptions,
) -> String {
let mut html = String::new();
let mut text_buffer = String::new();
let mut marker_stripped = false;
let flush_text = |html: &mut String, buffer: &mut String, stripped: &mut bool| {
if buffer.is_empty() {
return;
}
let text = if !*stripped {
*stripped = true;
strip_req_marker(buffer)
} else {
std::mem::take(buffer)
};
if !text.is_empty() {
html.push_str(&html_escape(&text));
}
buffer.clear();
};
for (event, _range) in events {
match event {
Event::Text(t) => {
text_buffer.push_str(t.as_ref());
}
Event::SoftBreak => {
text_buffer.push('\n');
}
Event::HardBreak => {
flush_text(&mut html, &mut text_buffer, &mut marker_stripped);
html.push_str("<br />\n");
}
Event::Code(code) => {
flush_text(&mut html, &mut text_buffer, &mut marker_stripped);
html.push_str(&render_inline_code(
code,
options.inline_code_handler.as_ref(),
));
}
Event::Start(Tag::Paragraph) => {
html.push_str("<p>");
}
Event::End(TagEnd::Paragraph) => {
flush_text(&mut html, &mut text_buffer, &mut marker_stripped);
html.push_str("</p>\n");
}
Event::Start(Tag::Emphasis) => {
flush_text(&mut html, &mut text_buffer, &mut marker_stripped);
html.push_str("<em>");
}
Event::End(TagEnd::Emphasis) => {
flush_text(&mut html, &mut text_buffer, &mut marker_stripped);
html.push_str("</em>");
}
Event::Start(Tag::Strong) => {
flush_text(&mut html, &mut text_buffer, &mut marker_stripped);
html.push_str("<strong>");
}
Event::End(TagEnd::Strong) => {
flush_text(&mut html, &mut text_buffer, &mut marker_stripped);
html.push_str("</strong>");
}
Event::Start(Tag::Link {
dest_url, title, ..
}) => {
flush_text(&mut html, &mut text_buffer, &mut marker_stripped);
let resolved = resolve_link_with_resolver(
dest_url,
options.source_path.as_deref(),
options.link_resolver.as_ref(),
)
.await;
let title_attr = if title.is_empty() {
String::new()
} else {
format!(" title=\"{}\"", html_escape(title))
};
html.push_str(&format!(
"<a href=\"{}\"{}>",
html_escape(&resolved),
title_attr
));
}
Event::End(TagEnd::Link) => {
flush_text(&mut html, &mut text_buffer, &mut marker_stripped);
html.push_str("</a>");
}
_ => {
flush_text(&mut html, &mut text_buffer, &mut marker_stripped);
pulldown_cmark::html::push_html(&mut html, std::iter::once(event.clone()));
}
}
}
flush_text(&mut html, &mut text_buffer, &mut marker_stripped);
html
}
async fn render_blockquote_req_content(
events: &[(Event<'_>, Range<usize>)],
options: &RenderOptions,
default_code_handler: &BoxedHandler,
) -> Result<String> {
let mut html = String::new();
let mut text_buffer = String::new();
let mut marker_stripped = false;
let mut in_paragraph = false;
let mut in_code_block = false;
let mut code_block_lang = String::new();
let mut code_block_content = String::new();
let mut blockquote_depth: usize = 0;
let flush_text = |html: &mut String, buffer: &mut String, stripped: &mut bool| {
if buffer.is_empty() {
return;
}
let text = if !*stripped {
*stripped = true;
strip_req_marker(buffer)
} else {
std::mem::take(buffer)
};
if !text.is_empty() {
html.push_str(&html_escape(&text));
}
buffer.clear();
};
for (event, _range) in events {
match event {
Event::Start(Tag::BlockQuote(_)) => {
if blockquote_depth > 0 {
flush_text(&mut html, &mut text_buffer, &mut marker_stripped);
html.push_str("<blockquote>");
}
blockquote_depth += 1;
}
Event::End(TagEnd::BlockQuote(_)) => {
blockquote_depth -= 1;
if blockquote_depth > 0 {
flush_text(&mut html, &mut text_buffer, &mut marker_stripped);
html.push_str("</blockquote>");
}
}
Event::Start(Tag::Paragraph) => {
html.push_str("<p>");
in_paragraph = true;
}
Event::End(TagEnd::Paragraph) => {
flush_text(&mut html, &mut text_buffer, &mut marker_stripped);
html.push_str("</p>\n");
in_paragraph = false;
}
Event::Start(Tag::CodeBlock(kind)) => {
flush_text(&mut html, &mut text_buffer, &mut marker_stripped);
in_code_block = true;
code_block_lang = match kind {
CodeBlockKind::Fenced(lang) => lang.split(',').next().unwrap_or("").to_string(),
CodeBlockKind::Indented => String::new(),
};
code_block_content.clear();
}
Event::End(TagEnd::CodeBlock) => {
in_code_block = false;
let handler = options
.code_handlers
.get(&code_block_lang)
.or(options.default_handler.as_ref())
.unwrap_or(default_code_handler);
let code_trimmed = code_block_content.trim_end_matches('\n');
let output = handler.render(&code_block_lang, code_trimmed).await?;
html.push_str(&output.html);
}
Event::Text(t) if in_code_block => {
code_block_content.push_str(t);
}
Event::Text(t) => {
text_buffer.push_str(t.as_ref());
}
Event::SoftBreak if in_paragraph => {
text_buffer.push('\n');
}
Event::HardBreak if in_paragraph => {
flush_text(&mut html, &mut text_buffer, &mut marker_stripped);
html.push_str("<br />\n");
}
Event::Code(code) => {
flush_text(&mut html, &mut text_buffer, &mut marker_stripped);
html.push_str(&render_inline_code(
code,
options.inline_code_handler.as_ref(),
));
}
Event::Start(Tag::Emphasis) => {
flush_text(&mut html, &mut text_buffer, &mut marker_stripped);
html.push_str("<em>");
}
Event::End(TagEnd::Emphasis) => {
flush_text(&mut html, &mut text_buffer, &mut marker_stripped);
html.push_str("</em>");
}
Event::Start(Tag::Strong) => {
flush_text(&mut html, &mut text_buffer, &mut marker_stripped);
html.push_str("<strong>");
}
Event::End(TagEnd::Strong) => {
flush_text(&mut html, &mut text_buffer, &mut marker_stripped);
html.push_str("</strong>");
}
Event::Start(Tag::Link {
dest_url, title, ..
}) => {
flush_text(&mut html, &mut text_buffer, &mut marker_stripped);
let resolved = resolve_link_with_resolver(
dest_url,
options.source_path.as_deref(),
options.link_resolver.as_ref(),
)
.await;
let title_attr = if title.is_empty() {
String::new()
} else {
format!(" title=\"{}\"", html_escape(title))
};
html.push_str(&format!(
"<a href=\"{}\"{}>",
html_escape(&resolved),
title_attr
));
}
Event::End(TagEnd::Link) => {
flush_text(&mut html, &mut text_buffer, &mut marker_stripped);
html.push_str("</a>");
}
_ => {
if !in_code_block {
flush_text(&mut html, &mut text_buffer, &mut marker_stripped);
pulldown_cmark::html::push_html(&mut html, std::iter::once(event.clone()));
}
}
}
}
flush_text(&mut html, &mut text_buffer, &mut marker_stripped);
Ok(html)
}
fn try_parse_paragraph_req<'a>(
text: &str,
markdown: &str,
offset: usize,
seen_ids: &mut std::collections::HashSet<RuleId>,
seen_bases: &mut std::collections::HashSet<String>,
_paragraph_events: &[(Event<'a>, std::ops::Range<usize>)],
) -> Option<Result<ReqDefinition>> {
let (prefix, marker_content, marker_end) = parse_req_leading_marker(text)?;
let (req_id, metadata) = match parse_req_marker(marker_content) {
Ok(result) => result,
Err(e) => return Some(Err(e)),
};
if seen_ids.contains(&req_id) {
return Some(Err(crate::Error::DuplicateReq(req_id.to_string())));
}
if seen_bases.contains(&req_id.base) {
return Some(Err(crate::Error::DuplicateReq(format!(
"duplicate requirement base: {}",
req_id.base
))));
}
seen_ids.insert(req_id.clone());
seen_bases.insert(req_id.base.clone());
let line = offset_to_line(markdown, offset);
let anchor_id = format!("{}-{}", prefix, req_id);
let marker_len = marker_end + 1;
let raw = text[marker_len..].trim().to_string();
let html = String::new();
let req = ReqDefinition {
id: req_id,
anchor_id,
marker_span: SourceSpan {
offset,
length: marker_len,
},
span: SourceSpan {
offset,
length: text.len(),
},
line,
metadata,
raw,
html,
};
Some(Ok(req))
}
fn try_parse_blockquote_req(
first_para_text: &str,
markdown: &str,
offset: usize,
end_offset: usize,
seen_ids: &mut std::collections::HashSet<RuleId>,
seen_bases: &mut std::collections::HashSet<String>,
) -> Option<Result<ReqDefinition>> {
let (prefix, marker_content, marker_end) = parse_req_leading_marker(first_para_text)?;
let (req_id, metadata) = match parse_req_marker(marker_content) {
Ok(result) => result,
Err(e) => return Some(Err(e)),
};
if seen_ids.contains(&req_id) {
return Some(Err(crate::Error::DuplicateReq(req_id.to_string())));
}
if seen_bases.contains(&req_id.base) {
return Some(Err(crate::Error::DuplicateReq(format!(
"duplicate requirement base: {}",
req_id.base
))));
}
seen_ids.insert(req_id.clone());
seen_bases.insert(req_id.base.clone());
let line = offset_to_line(markdown, offset);
let anchor_id = format!("{}-{}", prefix, req_id);
let marker_len = marker_end + 1;
let full_source = &markdown[offset..end_offset];
let raw = if let Some(newline_pos) = full_source.find('\n') {
full_source[newline_pos + 1..].trim_end().to_string()
} else {
String::new()
};
let html = String::new();
let req = ReqDefinition {
id: req_id,
anchor_id,
marker_span: SourceSpan {
offset,
length: marker_len,
},
span: SourceSpan {
offset,
length: end_offset.saturating_sub(offset),
},
line,
metadata,
raw,
html,
};
Some(Ok(req))
}
fn parse_req_leading_marker(text: &str) -> Option<(&str, &str, usize)> {
let mut prefix_len = 0usize;
for ch in text.chars() {
if ch.is_ascii_lowercase() || ch.is_ascii_digit() {
prefix_len += ch.len_utf8();
} else {
break;
}
}
if prefix_len == 0 || text.as_bytes().get(prefix_len) != Some(&b'[') {
return None;
}
let marker_end = text.find(']')?;
if marker_end <= prefix_len + 1 {
return None;
}
let prefix = &text[..prefix_len];
let marker_content = &text[prefix_len + 1..marker_end];
Some((prefix, marker_content, marker_end))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_render_simple() {
let md = "# Hello\n\nWorld.";
let doc = render(md, &RenderOptions::default()).await.unwrap();
assert!(doc.html.contains("<h1"));
assert!(doc.html.contains("Hello"));
assert!(doc.html.contains("World"));
assert_eq!(doc.headings.len(), 1);
assert_eq!(doc.headings[0].title, "Hello");
assert_eq!(doc.headings[0].id, "hello");
assert_eq!(doc.headings[0].line, 1);
}
#[tokio::test]
async fn test_render_with_frontmatter() {
let md = "+++\ntitle = \"Test\"\nweight = 5\n+++\n# Content";
let doc = render(md, &RenderOptions::default()).await.unwrap();
assert!(doc.frontmatter.is_some());
let fm = doc.frontmatter.unwrap();
assert_eq!(fm.title, "Test");
assert_eq!(fm.weight, 5);
}
#[tokio::test]
async fn test_render_with_reqs() {
let md = "r[my.req] This MUST be followed.\n";
let doc = render(md, &RenderOptions::default()).await.unwrap();
assert_eq!(doc.reqs.len(), 1);
assert_eq!(doc.reqs[0].id, "my.req");
assert_eq!(doc.reqs[0].line, 1);
assert!(doc.html.contains("id=\"r-my.req\""));
}
#[tokio::test]
async fn test_render_req_with_links() {
let md = "r[data.postcard] All payloads MUST use [Postcard](https://postcard.jamesmunns.com/wire-format).\n";
let doc = render(md, &RenderOptions::default()).await.unwrap();
assert_eq!(doc.reqs.len(), 1);
assert_eq!(doc.reqs[0].id, "data.postcard");
assert!(
doc.html
.contains("<a href=\"https://postcard.jamesmunns.com/wire-format\">"),
"Link should be preserved in HTML: {}",
doc.html
);
assert!(
doc.html.contains("Postcard</a>"),
"Link text should be preserved: {}",
doc.html
);
}
#[tokio::test]
async fn test_render_req_with_formatting() {
let md = "r[fmt.req] Text with **bold**, *italic*, and `code`.\n";
let doc = render(md, &RenderOptions::default()).await.unwrap();
assert_eq!(doc.reqs.len(), 1);
assert!(
doc.html.contains("<strong>bold</strong>"),
"Bold should be preserved: {}",
doc.html
);
assert!(
doc.html.contains("<em>italic</em>"),
"Italic should be preserved: {}",
doc.html
);
assert!(
doc.html.contains("<code>code</code>"),
"Code should be preserved: {}",
doc.html
);
}
#[tokio::test]
async fn test_render_code_block_default() {
let md = "```rust\nfn main() {}\n```\n";
let doc = render(md, &RenderOptions::default()).await.unwrap();
assert!(doc.html.contains("<pre><code"));
assert!(doc.html.contains("fn main()"));
}
#[tokio::test]
#[cfg(feature = "highlight")]
async fn test_render_code_block_preserves_newlines() {
use crate::handlers::ArboriumHandler;
let md = r#"```rust
fn greet(name: &str) {
println!("Hello, {}!", name);
}
fn main() {
greet("World");
}
```
"#;
let opts = RenderOptions::new().with_default_handler(ArboriumHandler::new());
let doc = render(md, &opts).await.unwrap();
let code_start = doc.html.find("<code").expect("should have <code>");
let code_end = doc.html.find("</code>").expect("should have </code>");
let code_section = &doc.html[code_start..code_end];
let newlines = code_section.matches('\n').count();
assert!(
newlines >= 5,
"Code block should preserve newlines. Found {} newlines in:\n{}",
newlines,
code_section
);
}
#[tokio::test]
async fn test_render_with_custom_req_handler() {
use crate::handler::ReqHandler;
use crate::reqs::ReqDefinition;
use std::future::Future;
use std::pin::Pin;
struct CustomReqHandler;
impl ReqHandler for CustomReqHandler {
fn start<'a>(
&'a self,
req: &'a ReqDefinition,
) -> Pin<Box<dyn Future<Output = crate::Result<String>> + Send + 'a>> {
Box::pin(async move {
Ok(format!(
"<div class=\"custom-req\" data-req=\"{}\">",
req.id
))
})
}
fn end<'a>(
&'a self,
_req: &'a ReqDefinition,
) -> Pin<Box<dyn Future<Output = crate::Result<String>> + Send + 'a>> {
Box::pin(async move { Ok("</div>".to_string()) })
}
}
let md = "r[custom.test] Some requirement text.\n";
let opts = RenderOptions::new().with_req_handler(CustomReqHandler);
let doc = render(md, &opts).await.unwrap();
assert_eq!(doc.reqs.len(), 1);
assert_eq!(doc.reqs[0].id, "custom.test");
assert!(doc.html.contains("class=\"custom-req\""));
assert!(doc.html.contains("data-req=\"custom.test\""));
}
#[tokio::test]
async fn test_render_hierarchical_heading_ids() {
let md = r#"# Main Title
## Section A
Content A.
## Section B
Content B.
### Subsection B1
Details 1.
### Subsection B2
Details 2.
"#;
let doc = render(md, &RenderOptions::default()).await.unwrap();
assert_eq!(doc.headings.len(), 5);
assert_eq!(doc.headings[0].id, "main-title");
assert_eq!(doc.headings[1].id, "main-title--section-a");
assert_eq!(doc.headings[2].id, "main-title--section-b");
assert_eq!(doc.headings[3].id, "main-title--section-b--subsection-b1");
assert_eq!(doc.headings[4].id, "main-title--section-b--subsection-b2");
assert!(doc.html.contains(r#"id="main-title""#));
assert!(doc.html.contains(r#"id="main-title--section-a""#));
assert!(doc.html.contains(r#"id="main-title--section-b""#));
assert!(
doc.html
.contains(r#"id="main-title--section-b--subsection-b1""#)
);
assert!(
doc.html
.contains(r#"id="main-title--section-b--subsection-b2""#)
);
}
#[tokio::test]
async fn test_hierarchical_ids_reset_on_same_level() {
let md = r#"# Foo
## Bar
### Baz
## Qux
### Quux
"#;
let doc = render(md, &RenderOptions::default()).await.unwrap();
assert_eq!(doc.headings.len(), 5);
assert_eq!(doc.headings[0].id, "foo");
assert_eq!(doc.headings[1].id, "foo--bar");
assert_eq!(doc.headings[2].id, "foo--bar--baz");
assert_eq!(doc.headings[3].id, "foo--qux");
assert_eq!(doc.headings[4].id, "foo--qux--quux");
}
#[tokio::test]
async fn test_elements_in_document_order() {
let md = r#"# Heading 1
r[req.one] First requirement.
## Heading 2
r[req.two] Second requirement.
r[req.three] Third requirement.
# Heading 3
"#;
let doc = render(md, &RenderOptions::default()).await.unwrap();
assert_eq!(doc.elements.len(), 6);
assert!(matches!(&doc.elements[0], DocElement::Heading(h) if h.title == "Heading 1"));
assert!(matches!(&doc.elements[1], DocElement::Req(r) if r.id == "req.one"));
assert!(matches!(&doc.elements[2], DocElement::Heading(h) if h.title == "Heading 2"));
assert!(matches!(&doc.elements[3], DocElement::Req(r) if r.id == "req.two"));
assert!(matches!(&doc.elements[4], DocElement::Req(r) if r.id == "req.three"));
assert!(matches!(&doc.elements[5], DocElement::Heading(h) if h.title == "Heading 3"));
}
#[tokio::test]
async fn test_heading_line_numbers() {
let md = r#"# Line 1
Some text.
## Line 5
More text.
### Line 9
"#;
let doc = render(md, &RenderOptions::default()).await.unwrap();
assert_eq!(doc.headings.len(), 3);
assert_eq!(doc.headings[0].line, 1);
assert_eq!(doc.headings[1].line, 5);
assert_eq!(doc.headings[2].line, 9);
}
#[tokio::test]
async fn test_req_line_numbers() {
let md = r#"# Heading
r[req.one] First.
Text.
r[req.two] Second.
"#;
let doc = render(md, &RenderOptions::default()).await.unwrap();
assert_eq!(doc.reqs.len(), 2);
assert_eq!(doc.reqs[0].line, 3);
assert_eq!(doc.reqs[1].line, 7);
}
#[tokio::test]
async fn test_req_in_blockquote_simple() {
let md = "> r[my.req] This is a requirement in a blockquote.";
let doc = render(md, &RenderOptions::default()).await.unwrap();
eprintln!("HTML: {}", doc.html);
eprintln!("Reqs: {:?}", doc.reqs);
assert_eq!(doc.reqs.len(), 1);
assert_eq!(doc.reqs[0].id, "my.req");
assert!(
!doc.html.contains("<blockquote>"),
"Blockquote wrapper should be removed when it's a requirement. HTML: {}",
doc.html
);
assert!(doc.html.contains("id=\"r-my.req\""));
let req = &doc.reqs[0];
let marker_text =
&md[req.marker_span.offset..req.marker_span.offset + req.marker_span.length];
assert_eq!(
marker_text, "r[my.req]",
"marker_span should point to r[my.req], got: {}",
marker_text
);
}
#[tokio::test]
async fn test_req_in_blockquote_multiline() {
let md = r#"> r[my.req] First line of requirement.
> Second line continues.
> Third line ends."#;
let doc = render(md, &RenderOptions::default()).await.unwrap();
assert_eq!(doc.reqs.len(), 1);
assert_eq!(doc.reqs[0].id, "my.req");
assert!(
doc.html.contains("First line"),
"Should contain first line: {}",
doc.html
);
assert!(
doc.html.contains("Second line"),
"Should contain second line: {}",
doc.html
);
assert!(
doc.html.contains("Third line"),
"Should contain third line: {}",
doc.html
);
}
#[tokio::test]
async fn test_req_in_blockquote_with_code_block() {
let md = r#"> r[my.req] Requirement with code:
>
> ```rust
> fn main() {}
> ```"#;
let doc = render(md, &RenderOptions::default()).await.unwrap();
assert_eq!(doc.reqs.len(), 1);
assert_eq!(doc.reqs[0].id, "my.req");
assert!(
doc.html.contains("fn main()"),
"Code block should be in HTML: {}",
doc.html
);
}
#[tokio::test]
async fn test_req_in_blockquote_with_formatting() {
let md = "> r[fmt.req] Text with **bold** and *italic*.";
let doc = render(md, &RenderOptions::default()).await.unwrap();
assert_eq!(doc.reqs.len(), 1);
assert!(
doc.html.contains("<strong>bold</strong>"),
"Bold should be preserved: {}",
doc.html
);
assert!(
doc.html.contains("<em>italic</em>"),
"Italic should be preserved: {}",
doc.html
);
}
#[tokio::test]
async fn test_regular_blockquote_not_req() {
let md = r#"> This is just a regular blockquote.
> Not a requirement."#;
let doc = render(md, &RenderOptions::default()).await.unwrap();
assert_eq!(doc.reqs.len(), 0);
assert!(
doc.html.contains("<blockquote>"),
"Regular blockquote should be preserved: {}",
doc.html
);
}
#[tokio::test]
async fn test_mixed_reqs_paragraph_and_blockquote() {
let md = r#"r[para.req] This is a paragraph requirement.
> r[quote.req] This is a blockquote requirement."#;
let doc = render(md, &RenderOptions::default()).await.unwrap();
assert_eq!(doc.reqs.len(), 2);
assert_eq!(doc.reqs[0].id, "para.req");
assert_eq!(doc.reqs[1].id, "quote.req");
}
#[tokio::test]
async fn test_blockquote_req_with_link() {
let md = "> r[link.req] See [the docs](https://example.com) for details.";
let doc = render(md, &RenderOptions::default()).await.unwrap();
assert_eq!(doc.reqs.len(), 1);
assert!(
doc.html.contains("<a href=\"https://example.com\">"),
"Link should be preserved: {}",
doc.html
);
}
#[tokio::test]
async fn test_blockquote_req_in_document_order() {
let md = r#"# Heading 1
r[para.req] Paragraph requirement.
> r[quote.req] Blockquote requirement.
## Heading 2
"#;
let doc = render(md, &RenderOptions::default()).await.unwrap();
assert_eq!(doc.elements.len(), 4);
assert!(matches!(&doc.elements[0], DocElement::Heading(h) if h.title == "Heading 1"));
assert!(matches!(&doc.elements[1], DocElement::Req(r) if r.id == "para.req"));
assert!(matches!(&doc.elements[2], DocElement::Req(r) if r.id == "quote.req"));
assert!(matches!(&doc.elements[3], DocElement::Heading(h) if h.title == "Heading 2"));
}
#[tokio::test]
async fn test_paragraph_line_numbers() {
let md = r#"First paragraph.
Second paragraph.
# Heading
Third paragraph.
"#;
let doc = render(md, &RenderOptions::default()).await.unwrap();
let paragraphs: Vec<_> = doc
.elements
.iter()
.filter_map(|e| match e {
DocElement::Paragraph(p) => Some(p),
_ => None,
})
.collect();
assert_eq!(paragraphs.len(), 3);
assert_eq!(paragraphs[0].line, 1);
assert_eq!(paragraphs[0].offset, 0);
assert_eq!(paragraphs[1].line, 3);
assert_eq!(paragraphs[2].line, 7);
}
#[tokio::test]
async fn test_paragraph_with_frontmatter_offset() {
let md = r#"+++
title = "Test"
+++
First paragraph after frontmatter.
Second paragraph.
"#;
let doc = render(md, &RenderOptions::default()).await.unwrap();
let paragraphs: Vec<_> = doc
.elements
.iter()
.filter_map(|e| match e {
DocElement::Paragraph(p) => Some(p),
_ => None,
})
.collect();
assert_eq!(paragraphs.len(), 2);
assert_eq!(paragraphs[0].line, 5);
assert_eq!(paragraphs[1].line, 7);
}
#[tokio::test]
async fn test_elements_include_paragraphs_in_order() {
let md = r#"# Heading 1
Regular paragraph.
r[my.req] A requirement definition.
Another paragraph.
## Heading 2
"#;
let doc = render(md, &RenderOptions::default()).await.unwrap();
assert_eq!(doc.elements.len(), 5);
assert!(matches!(&doc.elements[0], DocElement::Heading(h) if h.title == "Heading 1"));
assert!(matches!(&doc.elements[1], DocElement::Paragraph(p) if p.line == 3));
assert!(matches!(&doc.elements[2], DocElement::Req(r) if r.id == "my.req"));
assert!(matches!(&doc.elements[3], DocElement::Paragraph(p) if p.line == 7));
assert!(matches!(&doc.elements[4], DocElement::Heading(h) if h.title == "Heading 2"));
}
#[tokio::test]
async fn test_paragraph_html_has_source_line_attribute() {
let md = r#"First paragraph.
Second paragraph.
Third paragraph.
"#;
let doc = render(md, &RenderOptions::default()).await.unwrap();
assert!(
doc.html.contains(r#"<p data-source-line="1">"#),
"First paragraph should have data-source-line=\"1\": {}",
doc.html
);
assert!(
doc.html.contains(r#"<p data-source-line="3">"#),
"Second paragraph should have data-source-line=\"3\": {}",
doc.html
);
assert!(
doc.html.contains(r#"<p data-source-line="5">"#),
"Third paragraph should have data-source-line=\"5\": {}",
doc.html
);
}
#[tokio::test]
async fn test_paragraph_html_has_source_file_attribute() {
let md = "A paragraph with source file info.";
let opts = RenderOptions {
source_path: Some("docs/test.md".to_string()),
..Default::default()
};
let doc = render(md, &opts).await.unwrap();
assert!(
doc.html.contains(r#"data-source-line="1""#),
"Should have line attribute: {}",
doc.html
);
assert!(
doc.html.contains(r#"data-source-file="docs/test.md""#),
"Should have file attribute: {}",
doc.html
);
}
#[test]
fn test_strip_req_marker_basic() {
assert_eq!(strip_req_marker("r[foo] bar"), "bar");
assert_eq!(strip_req_marker("req[foo] bar"), "bar");
assert_eq!(strip_req_marker("r[foo.bar] text"), "text");
assert_eq!(strip_req_marker("r[foo]"), "");
assert_eq!(
strip_req_marker("r[foo.bar.baz status=stable] text"),
"text"
);
assert_eq!(strip_req_marker("no marker here"), "no marker here");
assert_eq!(strip_req_marker(""), "");
}
#[tokio::test]
async fn test_req_marker_same_line() {
let md = "r[same.line] This text is on the same line.";
let doc = render(md, &RenderOptions::default()).await.unwrap();
eprintln!("HTML: {}", doc.html);
assert_eq!(doc.reqs.len(), 1);
assert_eq!(doc.reqs[0].id, "same.line");
assert!(
!doc.html.contains("r[same.line]"),
"Raw marker should be stripped: {}",
doc.html
);
assert!(
!doc.html.contains("[same.line]"),
"Marker brackets should be stripped from content: {}",
doc.html
);
assert!(
doc.html.contains("This text is on the same line"),
"Text should be present: {}",
doc.html
);
}
#[tokio::test]
async fn test_req_marker_non_r_prefix_same_line() {
let md = "req[same.line] This text is on the same line.";
let doc = render(md, &RenderOptions::default()).await.unwrap();
assert_eq!(doc.reqs.len(), 1);
assert_eq!(doc.reqs[0].id, "same.line");
assert_eq!(doc.reqs[0].anchor_id, "req-same.line");
assert!(
!doc.html.contains("req[same.line]"),
"Raw marker should be stripped: {}",
doc.html
);
assert!(
doc.html.contains("This text is on the same line"),
"Text should be present: {}",
doc.html
);
}
#[tokio::test]
async fn test_req_marker_on_own_line() {
let md = "r[own.line]\nText on next line.";
let doc = render(md, &RenderOptions::default()).await.unwrap();
eprintln!("HTML: {}", doc.html);
assert_eq!(doc.reqs.len(), 1);
assert_eq!(doc.reqs[0].id, "own.line");
assert!(
!doc.html.contains("r[own.line]"),
"Raw marker should be stripped: {}",
doc.html
);
assert!(
!doc.html.contains("[own.line]"),
"Marker brackets should be stripped: {}",
doc.html
);
assert!(
doc.html.contains("Text on next line"),
"Text should be present: {}",
doc.html
);
}
#[tokio::test]
async fn test_req_marker_with_blank_line() {
let md = "r[blank.after]\n\nText after blank line.";
let doc = render(md, &RenderOptions::default()).await.unwrap();
eprintln!("HTML: {}", doc.html);
assert_eq!(doc.reqs.len(), 1);
assert_eq!(doc.reqs[0].id, "blank.after");
assert!(
!doc.html.contains("r[blank.after]"),
"Raw marker should be stripped: {}",
doc.html
);
assert!(
!doc.html.contains("[blank.after]"),
"Marker brackets should be stripped: {}",
doc.html
);
}
#[tokio::test]
async fn test_req_marker_with_metadata() {
let md = "r[meta.req status=stable level=must] Requirement with metadata.";
let doc = render(md, &RenderOptions::default()).await.unwrap();
eprintln!("HTML: {}", doc.html);
assert_eq!(doc.reqs.len(), 1);
assert_eq!(doc.reqs[0].id, "meta.req");
assert!(
!doc.html.contains("r[meta.req"),
"Raw marker should be stripped: {}",
doc.html
);
assert!(
!doc.html.contains("status=stable"),
"Metadata should be stripped from content: {}",
doc.html
);
assert!(
doc.html.contains("Requirement with metadata"),
"Text should be present: {}",
doc.html
);
}
#[tokio::test]
async fn test_req_in_blockquote_marker_stripped() {
let md = "> r[quote.req] Text in blockquote requirement.";
let doc = render(md, &RenderOptions::default()).await.unwrap();
eprintln!("HTML: {}", doc.html);
assert_eq!(doc.reqs.len(), 1);
assert_eq!(doc.reqs[0].id, "quote.req");
assert!(
!doc.html.contains("r[quote.req]"),
"Raw marker should be stripped: {}",
doc.html
);
assert!(
!doc.html.contains("[quote.req]"),
"Marker brackets should be stripped: {}",
doc.html
);
assert!(
doc.html.contains("Text in blockquote requirement"),
"Text should be present: {}",
doc.html
);
}
#[tokio::test]
async fn test_req_in_blockquote_multiline_marker_stripped() {
let md = "> r[multiline.quote]\n> Text continues here.";
let doc = render(md, &RenderOptions::default()).await.unwrap();
eprintln!("HTML: {}", doc.html);
assert_eq!(doc.reqs.len(), 1);
assert_eq!(doc.reqs[0].id, "multiline.quote");
assert!(
!doc.html.contains("r[multiline.quote]"),
"Raw marker should be stripped: {}",
doc.html
);
assert!(
!doc.html.contains("[multiline.quote]"),
"Marker brackets should be stripped: {}",
doc.html
);
assert!(
doc.html.contains("Text continues here"),
"Text should be present: {}",
doc.html
);
}
#[tokio::test]
async fn test_multiple_reqs_markers_stripped() {
let md = "r[first.req] First requirement text.\n\nr[second.req] Second requirement text.";
let doc = render(md, &RenderOptions::default()).await.unwrap();
eprintln!("HTML: {}", doc.html);
assert_eq!(doc.reqs.len(), 2);
assert!(
!doc.html.contains("r[first.req]"),
"First marker should be stripped: {}",
doc.html
);
assert!(
!doc.html.contains("r[second.req]"),
"Second marker should be stripped: {}",
doc.html
);
assert!(
doc.html.contains("First requirement text"),
"First text should be present: {}",
doc.html
);
assert!(
doc.html.contains("Second requirement text"),
"Second text should be present: {}",
doc.html
);
}
#[tokio::test]
async fn test_req_only_marker_no_text() {
let md = "r[lonely.req]";
let doc = render(md, &RenderOptions::default()).await.unwrap();
eprintln!("HTML: {}", doc.html);
assert_eq!(doc.reqs.len(), 1);
assert_eq!(doc.reqs[0].id, "lonely.req");
assert!(
!doc.html.contains("r[lonely.req]"),
"Raw marker should not appear: {}",
doc.html
);
}
#[tokio::test]
async fn test_req_with_formatting_after_marker() {
let md = "r[fmt.after] Text with **bold** and *italic*.";
let doc = render(md, &RenderOptions::default()).await.unwrap();
eprintln!("HTML: {}", doc.html);
assert_eq!(doc.reqs.len(), 1);
assert!(
!doc.html.contains("r[fmt.after]"),
"Marker should be stripped: {}",
doc.html
);
assert!(
doc.html.contains("<strong>bold</strong>"),
"Bold should be rendered: {}",
doc.html
);
assert!(
doc.html.contains("<em>italic</em>"),
"Italic should be rendered: {}",
doc.html
);
}
#[tokio::test]
async fn test_absolute_at_link_resolved() {
let md = r#"Check out [structstruck](@/guide/structstruck.md) for more info."#;
let doc = render(md, &RenderOptions::default()).await.unwrap();
eprintln!("HTML: {}", doc.html);
assert!(
!doc.html.contains("@/"),
"@/ prefix should be resolved, not left in HTML: {}",
doc.html
);
assert!(
doc.html.contains(r#"href="/guide/structstruck/""#),
"Link should resolve to /guide/structstruck/: {}",
doc.html
);
}
#[tokio::test]
async fn test_absolute_at_link_with_backticks_in_text() {
let md = r#"- [`structstruck`](@/guide/structstruck.md) — generate structs"#;
let doc = render(md, &RenderOptions::default()).await.unwrap();
eprintln!("HTML: {}", doc.html);
assert!(
!doc.html.contains("@/"),
"@/ prefix should be resolved even with backticks in link text: {}",
doc.html
);
assert!(
doc.html.contains(r#"href="/guide/structstruck/""#),
"Link should resolve to /guide/structstruck/: {}",
doc.html
);
assert!(
doc.html.contains("<code>structstruck</code>"),
"Backticks should render as code: {}",
doc.html
);
}
#[tokio::test]
async fn test_absolute_at_link_with_fragment() {
let md = r#"See [the section](@/guide/intro.md#getting-started) for details."#;
let doc = render(md, &RenderOptions::default()).await.unwrap();
eprintln!("HTML: {}", doc.html);
assert!(
!doc.html.contains("@/"),
"@/ prefix should be resolved: {}",
doc.html
);
assert!(
doc.html.contains(r#"href="/guide/intro/#getting-started""#),
"Link should resolve with fragment: {}",
doc.html
);
}
#[tokio::test]
async fn test_absolute_at_link_to_index() {
let md = r#"Go to [the guide](@/guide/_index.md) section."#;
let doc = render(md, &RenderOptions::default()).await.unwrap();
eprintln!("HTML: {}", doc.html);
assert!(
!doc.html.contains("@/"),
"@/ prefix should be resolved: {}",
doc.html
);
assert!(
doc.html.contains(r#"href="/guide/""#),
"_index.md should resolve to parent directory: {}",
doc.html
);
}
#[tokio::test]
async fn test_relative_md_link_resolved() {
let md = r#"See [sibling](sibling.md) for more."#;
let opts = RenderOptions {
source_path: Some("guide/current.md".to_string()),
..Default::default()
};
let doc = render(md, &opts).await.unwrap();
eprintln!("HTML: {}", doc.html);
assert!(
doc.html.contains(r#"href="/guide/sibling/""#),
"Relative .md link should resolve to /guide/sibling/: {}",
doc.html
);
assert!(
!doc.html.contains(r#"href="sibling.md""#),
"Original .md link should not appear in href: {}",
doc.html
);
}
#[tokio::test]
async fn test_external_link_unchanged() {
let md = r#"Visit [example](https://example.com/page.md) for docs."#;
let doc = render(md, &RenderOptions::default()).await.unwrap();
eprintln!("HTML: {}", doc.html);
assert!(
doc.html.contains(r#"href="https://example.com/page.md""#),
"External link should be unchanged: {}",
doc.html
);
}
#[tokio::test]
async fn test_head_injections_collected() {
use crate::handlers::MermaidHandler;
let md = "```mermaid\ngraph TD\n A-->B\n```\n";
let opts = RenderOptions::new().with_handler(&["mermaid"], MermaidHandler::new());
let doc = render(md, &opts).await.unwrap();
assert_eq!(
doc.head_injections.len(),
1,
"Should have exactly one head injection"
);
assert!(
doc.head_injections[0].contains("mermaid"),
"Head injection should contain mermaid script"
);
}
#[tokio::test]
async fn test_head_injections_deduplicated() {
use crate::handlers::MermaidHandler;
let md = "```mermaid\ngraph TD\n A-->B\n```\n\n```mermaid\ngraph LR\n X-->Y\n```\n";
let opts = RenderOptions::new().with_handler(&["mermaid"], MermaidHandler::new());
let doc = render(md, &opts).await.unwrap();
assert_eq!(
doc.head_injections.len(),
1,
"Two mermaid blocks should produce only one head injection, got: {}",
doc.head_injections.len()
);
}
#[tokio::test]
async fn test_req_in_blockquote_with_nested_blockquote() {
let md = "> r[my.rule]\n> > quoted text";
let doc = render(md, &RenderOptions::default()).await.unwrap();
eprintln!("HTML: {}", doc.html);
assert_eq!(doc.reqs.len(), 1);
assert_eq!(doc.reqs[0].id, "my.rule");
assert!(
!doc.html.starts_with("<blockquote>"),
"Outer blockquote should be replaced by req div: {}",
doc.html
);
assert!(
doc.html.contains("<blockquote>"),
"Nested blockquote should be rendered: {}",
doc.html
);
assert!(
doc.html.contains("quoted text"),
"Nested blockquote text should be present: {}",
doc.html
);
assert!(
doc.reqs[0].raw.contains("> quoted text"),
"req.raw should include nested blockquote source: {}",
doc.reqs[0].raw
);
}
#[tokio::test]
async fn test_req_in_blockquote_with_deeply_nested_blockquote() {
let md = "> r[my.rule]\n> > > deeply quoted";
let doc = render(md, &RenderOptions::default()).await.unwrap();
eprintln!("HTML: {}", doc.html);
assert_eq!(doc.reqs.len(), 1);
assert_eq!(doc.reqs[0].id, "my.rule");
assert!(
!doc.html.starts_with("<blockquote>"),
"Outer blockquote should be replaced by req div: {}",
doc.html
);
let blockquote_count = doc.html.matches("<blockquote>").count();
assert_eq!(
blockquote_count, 2,
"Should have two levels of nested blockquotes, got {}: {}",
blockquote_count, doc.html
);
assert!(
doc.html.contains("deeply quoted"),
"Deeply nested text should be present: {}",
doc.html
);
assert!(
doc.reqs[0].raw.contains("> > deeply quoted"),
"req.raw should include deeply nested blockquote source: {}",
doc.reqs[0].raw
);
}
#[tokio::test]
async fn test_mermaid_code_block_renders_client_side() {
use crate::handlers::MermaidHandler;
let md = "```mermaid\ngraph TD\n A-->B\n```\n";
let opts = RenderOptions::new().with_handler(&["mermaid"], MermaidHandler::new());
let doc = render(md, &opts).await.unwrap();
assert!(
doc.html.contains("data-hotmeal-opaque=\"mermaid\""),
"Should have hotmeal opaque wrapper: {}",
doc.html
);
assert!(
doc.html.contains("<pre class=\"mermaid\">"),
"Should have pre.mermaid: {}",
doc.html
);
assert!(
doc.html.contains("A-->B"),
"Mermaid code should be HTML-escaped: {}",
doc.html
);
}
#[tokio::test]
async fn test_inline_code_spans_collected() {
let md = "See `r[auth.login]` and `r[data.format]` for details.";
let doc = render(md, &RenderOptions::default()).await.unwrap();
assert_eq!(doc.inline_code_spans.len(), 2);
assert_eq!(doc.inline_code_spans[0].content, "r[auth.login]");
assert_eq!(doc.inline_code_spans[1].content, "r[data.format]");
assert!(doc.inline_code_spans[0].span.length > "r[auth.login]".len());
}
#[tokio::test]
async fn test_inline_code_spans_skip_fenced_code_blocks() {
let md = "```rust\n// r[auth.login]\n```\n\nSee `r[real.ref]` here.";
let doc = render(md, &RenderOptions::default()).await.unwrap();
assert_eq!(doc.inline_code_spans.len(), 1);
assert_eq!(doc.inline_code_spans[0].content, "r[real.ref]");
}
#[tokio::test]
async fn test_inline_code_spans_skip_blockquoted_fenced_code_blocks() {
let md = "> ```rust\n> // r[auth.login]\n> ```\n\nSee `r[real.ref]` here.";
let doc = render(md, &RenderOptions::default()).await.unwrap();
assert_eq!(doc.inline_code_spans.len(), 1);
assert_eq!(doc.inline_code_spans[0].content, "r[real.ref]");
}
#[tokio::test]
async fn test_inline_code_spans_inside_blockquote_prose() {
let md = "> See `r[auth.login]` for details.";
let doc = render(md, &RenderOptions::default()).await.unwrap();
assert_eq!(doc.inline_code_spans.len(), 1);
assert_eq!(doc.inline_code_spans[0].content, "r[auth.login]");
}
}