use std::{path::Path, sync::Arc};
use glob::glob as glob_fs;
use log::warn;
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd, html::push_html};
use serde::de::DeserializeOwned;
pub mod components;
pub mod shortcodes;
use components::{LinkType, ListType, MarkdownComponents, TableAlignment};
use crate::{
assets::Asset,
content::{
ContentContext, ContentEntry, Dependency, Entry,
shortcodes::{MarkdownShortcodes, preprocess_shortcodes},
},
route::PageContext,
};
use super::{highlight::CodeBlock, slugger};
#[cfg(test)]
mod shortcodes_tests;
#[derive(Debug, Clone)]
pub struct MarkdownHeading {
pub title: String,
pub id: String,
pub level: u8,
pub classes: Vec<String>,
}
#[derive(Debug)]
struct InternalHeadingEvent {
start: usize,
end: usize,
id: Option<String>,
level: u32,
classes: Vec<String>,
}
impl InternalHeadingEvent {
fn new(start: usize, level: u32, id: Option<String>, classes: &[String]) -> Self {
Self {
start,
end: 0,
id,
level,
classes: classes.to_vec(),
}
}
}
#[doc(hidden)]
pub trait MarkdownContent {
fn get_headings(&self) -> &Vec<MarkdownHeading>;
}
#[doc(hidden)]
pub trait InternalMarkdownContent {
fn set_headings(&mut self, headings: Vec<MarkdownHeading>);
}
#[derive(serde::Deserialize, Debug, Clone)]
pub struct UntypedMarkdownContent {
#[serde(skip)]
__internal_headings: Vec<MarkdownHeading>,
}
impl MarkdownContent for UntypedMarkdownContent {
fn get_headings(&self) -> &Vec<MarkdownHeading> {
&self.__internal_headings
}
}
impl InternalMarkdownContent for UntypedMarkdownContent {
fn set_headings(&mut self, headings: Vec<MarkdownHeading>) {
self.__internal_headings = headings;
}
}
pub struct MarkdownOptions {
pub highlight_theme: String,
pub components: MarkdownComponents,
pub shortcodes: MarkdownShortcodes,
}
impl Default for MarkdownOptions {
fn default() -> Self {
Self {
highlight_theme: "base16-ocean.dark".to_string(),
components: MarkdownComponents::default(),
shortcodes: MarkdownShortcodes::default(),
}
}
}
impl MarkdownOptions {
pub fn new() -> Self {
Self::default()
}
pub fn with_components(components: MarkdownComponents, shortcodes: MarkdownShortcodes) -> Self {
Self {
components,
shortcodes,
..Self::default()
}
}
}
pub fn glob_markdown_with_options<T>(pattern: &str, options: MarkdownOptions) -> Vec<Entry<T>>
where
T: DeserializeOwned + MarkdownContent + InternalMarkdownContent + Send + Sync + 'static,
{
let options = Some(options);
let mut entries = vec![];
let options = options.map(Arc::new);
for entry in glob_fs(pattern).unwrap() {
let entry = entry.unwrap();
if let Some(extension) = entry.extension()
&& extension != "md"
{
warn!("Other file types than Markdown are not supported yet");
continue;
}
let id = entry.file_stem().unwrap().to_str().unwrap().to_string();
let content = std::fs::read_to_string(&entry).unwrap();
let content_clone = content.clone();
let data_loader = Box::new(move |_: &mut dyn ContentContext| {
parse_markdown_with_frontmatter(&content_clone)
});
let opts = options.clone();
let path = entry.clone();
entries.push(Entry::create_lazy(
id,
Some(Box::new(move |content: &str, route_ctx| {
render_markdown(content, opts.as_deref(), Some(&path), Some(route_ctx))
})),
Some(content),
data_loader,
vec![Dependency::File(entry)],
));
}
entries
}
pub fn glob_markdown<T>(pattern: &str) -> Vec<Entry<T>>
where
T: DeserializeOwned + MarkdownContent + InternalMarkdownContent + Send + Sync + 'static,
{
glob_markdown_with_options(pattern, MarkdownOptions::default())
}
fn get_text_from_events(events_slice: &[Event]) -> String {
events_slice.iter().fold(String::new(), |mut acc, event| {
match event {
Event::Text(text) | Event::Code(text) => acc.push_str(text),
_ => {}
}
acc
})
}
fn find_headings(events: &[Event]) -> Vec<InternalHeadingEvent> {
let mut headings = Vec::new();
for (i, event) in events.iter().enumerate() {
match event {
Event::Start(Tag::Heading {
level, id, classes, ..
}) => {
headings.push(InternalHeadingEvent::new(
i,
*level as u32,
id.as_ref().map(|s| s.to_string()),
&classes.iter().map(|c| c.to_string()).collect::<Vec<_>>(),
));
}
Event::End(TagEnd::Heading { .. }) => {
if let Some(heading) = headings.last_mut() {
heading.end = i;
}
}
_ => {}
}
}
headings
}
pub fn render_markdown(
content: &str,
options: Option<&MarkdownOptions>,
path: Option<&Path>,
mut route_ctx: Option<&mut PageContext>,
) -> String {
let content = if let Some(shortcodes) = options.map(|o| &o.shortcodes)
&& !shortcodes.is_empty()
{
preprocess_shortcodes(
content,
shortcodes,
route_ctx.as_deref_mut(),
path.and_then(|p| p.to_str()),
)
.unwrap_or_else(|e| panic!("Failed to preprocess shortcodes for {:?}: {}", path, e))
} else {
content.to_string()
};
let mut slugger = slugger::Slugger::new();
let mut html_output = String::new();
let parser_options = Options::ENABLE_YAML_STYLE_METADATA_BLOCKS
| Options::ENABLE_STRIKETHROUGH
| Options::ENABLE_TASKLISTS
| Options::ENABLE_TABLES
| Options::ENABLE_GFM
| Options::ENABLE_MATH
| Options::ENABLE_FOOTNOTES;
let mut code_block = None;
let mut code_block_content = String::new();
let mut in_frontmatter = false;
let mut in_image = false;
let mut events = Parser::new_ext(&content, parser_options).collect::<Vec<Event>>();
let options_with_components = options
.as_ref()
.filter(|o| o.components.has_any_components());
for i in 0..events.len() {
match &events[i] {
Event::Start(Tag::MetadataBlock(_)) => {
in_frontmatter = true;
continue;
}
Event::End(TagEnd::MetadataBlock(_)) => {
in_frontmatter = false;
continue;
}
Event::Start(Tag::Image {
dest_url,
link_type,
id,
title,
}) => {
let new_event = if dest_url.starts_with("./") || dest_url.starts_with("../") {
path.and_then(|p| p.parent())
.and_then(|parent| {
let resolved = parent.join(dest_url.to_string());
route_ctx
.as_mut()
.and_then(|ctx| ctx.assets.add_image(resolved).ok())
.map(|image| image.url().clone())
})
.map(|image_url| {
Event::Start(Tag::Image {
dest_url: image_url.into(),
title: title.clone(),
link_type: *link_type,
id: id.clone(),
})
})
} else {
None
};
if let Some(event) = new_event {
events[i] = event;
}
}
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(fence))) => {
let (block, begin) = CodeBlock::new(
fence,
&options
.unwrap_or(&MarkdownOptions::default())
.highlight_theme,
);
code_block = Some(block);
events[i] = Event::Html(begin.into());
}
Event::End(TagEnd::CodeBlock) => {
if let Some(ref mut code_block) = code_block {
let html = code_block.highlight(&code_block_content);
events[i] =
Event::Html(format!("{}{}", html.unwrap(), "</code></pre>\n").into());
}
code_block = None;
code_block_content.clear();
}
Event::Text(text) => {
if !in_frontmatter {
if in_image {
events[i] = Event::Html("".into());
} else if code_block.is_some() {
code_block_content.push_str(text);
events[i] = Event::Html("".into());
}
} else {
events[i] = Event::Html("".into());
}
}
Event::Start(Tag::Heading {
level, id, classes, ..
}) => {
let heading_content = if let Some(end_index) = find_matching_heading_end(&events, i)
{
get_text_from_events(&events[i + 1..end_index])
} else {
String::new()
};
let slug = slugger.slugify(&heading_content);
let heading_id = id.as_ref().map(|s| s.as_ref()).unwrap_or(&slug);
if let Some(component) = options.and_then(|opts| opts.components.heading.as_ref()) {
let classes_vec: Vec<&str> = classes.iter().map(|c| c.as_ref()).collect();
let custom_html =
component.render_start(*level as u8, Some(heading_id), &classes_vec);
events[i] = Event::Html(custom_html.into());
} else {
events[i] = Event::Html(
format!(
"<{} id=\"{}\" class=\"{}\">",
level,
heading_id,
classes.join(" ")
)
.into(),
);
}
}
Event::End(TagEnd::Heading(level)) => {
if let Some(component) = options.and_then(|opts| opts.components.heading.as_ref()) {
let custom_html = component.render_end(*level as u8);
events[i] = Event::Html(custom_html.into());
} else {
events[i] = Event::Html(format!("</h{}>", *level as u32).into());
}
}
_ => {}
}
if let Some(options) = options_with_components {
match &events[i] {
Event::Start(Tag::Paragraph) => {
if let Some(component) = &options.components.paragraph {
let custom_html = component.render_start();
events[i] = Event::Html(custom_html.into());
}
}
Event::End(TagEnd::Paragraph) => {
if let Some(component) = &options.components.paragraph {
let custom_html = component.render_end();
events[i] = Event::Html(custom_html.into());
}
}
Event::Start(Tag::Link {
link_type,
dest_url,
title,
..
}) => {
if let Some(component) = &options.components.link {
let link_type_converted: LinkType = link_type.into();
let title_str = if title.is_empty() {
None
} else {
Some(title.as_ref())
};
let custom_html = component.render_start(
dest_url.as_ref(),
title_str,
link_type_converted,
);
events[i] = Event::Html(custom_html.into());
}
}
Event::End(TagEnd::Link) => {
if let Some(component) = &options.components.link {
let custom_html = component.render_end();
events[i] = Event::Html(custom_html.into());
}
}
Event::Start(Tag::Image {
dest_url, title, ..
}) => {
in_image = true;
if let Some(component) = &options.components.image {
let alt_text = if let Some(end_index) = find_matching_image_end(&events, i)
{
get_text_from_events(&events[i + 1..end_index])
} else {
String::new()
};
let title_str = if title.is_empty() {
None
} else {
Some(title.as_ref())
};
let custom_html = component.render(dest_url.as_ref(), &alt_text, title_str);
events[i] = Event::Html(custom_html.into());
}
}
Event::End(TagEnd::Image) => {
in_image = false;
}
Event::Start(Tag::Strong) => {
if let Some(component) = &options.components.strong {
let custom_html = component.render_start();
events[i] = Event::Html(custom_html.into());
}
}
Event::End(TagEnd::Strong) => {
if let Some(component) = &options.components.strong {
let custom_html = component.render_end();
events[i] = Event::Html(custom_html.into());
}
}
Event::Start(Tag::Emphasis) => {
if let Some(component) = &options.components.emphasis {
let custom_html = component.render_start();
events[i] = Event::Html(custom_html.into());
}
}
Event::End(TagEnd::Emphasis) => {
if let Some(component) = &options.components.emphasis {
let custom_html = component.render_end();
events[i] = Event::Html(custom_html.into());
}
}
Event::Code(code) => {
if let Some(component) = &options.components.code {
let custom_html = component.render(code.as_ref());
events[i] = Event::Html(custom_html.into());
}
}
Event::Start(Tag::BlockQuote(kind)) => {
if let Some(component) = &options.components.blockquote {
let kind_converted = kind.as_ref().map(|k| k.into());
let custom_html = component.render_start(kind_converted);
events[i] = Event::Html(custom_html.into());
}
}
Event::End(TagEnd::BlockQuote(kind)) => {
if let Some(component) = &options.components.blockquote {
let kind_converted = kind.as_ref().map(|k| k.into());
let custom_html = component.render_end(kind_converted);
events[i] = Event::Html(custom_html.into());
}
}
Event::HardBreak => {
if let Some(component) = &options.components.hard_break {
let custom_html = component.render();
events[i] = Event::Html(custom_html.into());
}
}
Event::Rule => {
if let Some(component) = &options.components.horizontal_rule {
let custom_html = component.render();
events[i] = Event::Html(custom_html.into());
}
}
Event::Start(Tag::List(first_number)) => {
if let Some(component) = &options.components.list {
let list_type = if first_number.is_some() {
ListType::Ordered
} else {
ListType::Unordered
};
let custom_html = component.render_start(list_type, *first_number);
events[i] = Event::Html(custom_html.into());
}
}
Event::End(TagEnd::List(ordered)) => {
if let Some(component) = &options.components.list {
let list_type = if *ordered {
ListType::Ordered
} else {
ListType::Unordered
};
let custom_html = component.render_end(list_type);
events[i] = Event::Html(custom_html.into());
}
}
Event::Start(Tag::Item) => {
if let Some(component) = &options.components.list_item {
let custom_html = component.render_start();
events[i] = Event::Html(custom_html.into());
}
}
Event::End(TagEnd::Item) => {
if let Some(component) = &options.components.list_item {
let custom_html = component.render_end();
events[i] = Event::Html(custom_html.into());
}
}
Event::Start(Tag::Strikethrough) => {
if let Some(component) = &options.components.strikethrough {
let custom_html = component.render_start();
events[i] = Event::Html(custom_html.into());
}
}
Event::End(TagEnd::Strikethrough) => {
if let Some(component) = &options.components.strikethrough {
let custom_html = component.render_end();
events[i] = Event::Html(custom_html.into());
}
}
Event::TaskListMarker(checked) => {
if let Some(component) = &options.components.task_list_marker {
let custom_html = component.render(*checked);
events[i] = Event::Html(custom_html.into());
}
}
Event::Start(Tag::Table(alignments)) => {
if let Some(component) = &options.components.table {
let alignment_vec: Vec<TableAlignment> = alignments
.iter()
.map(|a| match a {
pulldown_cmark::Alignment::Left => TableAlignment::Left,
pulldown_cmark::Alignment::Center => TableAlignment::Center,
pulldown_cmark::Alignment::Right => TableAlignment::Right,
pulldown_cmark::Alignment::None => TableAlignment::Left,
})
.collect();
let custom_html = component.render_start(&alignment_vec);
events[i] = Event::Html(custom_html.into());
}
}
Event::End(TagEnd::Table) => {
if let Some(component) = &options.components.table {
let custom_html = component.render_end();
events[i] = Event::Html(custom_html.into());
}
}
Event::Start(Tag::TableHead) => {
if let Some(component) = &options.components.table_head {
let custom_html = component.render_start();
events[i] = Event::Html(custom_html.into());
}
}
Event::End(TagEnd::TableHead) => {
if let Some(component) = &options.components.table_head {
let custom_html = component.render_end();
events[i] = Event::Html(custom_html.into());
}
}
Event::Start(Tag::TableRow) => {
if let Some(component) = &options.components.table_row {
let custom_html = component.render_start();
events[i] = Event::Html(custom_html.into());
}
}
Event::End(TagEnd::TableRow) => {
if let Some(component) = &options.components.table_row {
let custom_html = component.render_end();
events[i] = Event::Html(custom_html.into());
}
}
Event::Start(Tag::TableCell) => {
if let Some(component) = &options.components.table_cell {
let custom_html = component.render_start(false, None);
events[i] = Event::Html(custom_html.into());
}
}
Event::End(TagEnd::TableCell) => {
if let Some(component) = &options.components.table_cell {
let custom_html = component.render_end(false);
events[i] = Event::Html(custom_html.into());
}
}
_ => {}
}
}
}
events.retain(|e| match e {
Event::Text(content) | Event::Html(content) => !content.is_empty(),
_ => true,
});
push_html(&mut html_output, events.into_iter());
html_output
}
pub fn parse_markdown_with_frontmatter<T>(content: &str) -> T
where
T: DeserializeOwned + MarkdownContent + InternalMarkdownContent,
{
let mut slugger = slugger::Slugger::new();
let mut options = Options::empty();
options.insert(Options::ENABLE_YAML_STYLE_METADATA_BLOCKS | Options::ENABLE_HEADING_ATTRIBUTES);
let mut frontmatter = String::new();
let mut in_frontmatter = false;
let mut content_events = Vec::new();
for (event, _) in Parser::new_ext(content, options).into_offset_iter() {
match event {
Event::Start(Tag::MetadataBlock(_)) => in_frontmatter = true,
Event::End(TagEnd::MetadataBlock(_)) => in_frontmatter = false,
Event::Text(ref text) => {
if in_frontmatter {
frontmatter.push_str(text);
} else {
content_events.push(event);
}
}
_ => content_events.push(event),
}
}
let mut parsed = serde_yaml::from_str::<T>(&frontmatter)
.unwrap_or_else(|e| panic!("Failed to parse YAML frontmatter: {}, {}", e, frontmatter));
let headings_internal = find_headings(&content_events);
let mut headings = vec![];
for heading in headings_internal {
let heading_content = get_text_from_events(&content_events[heading.start..heading.end]);
let slug: String = slugger.slugify(&heading_content);
headings.push(MarkdownHeading {
title: heading_content,
id: heading.id.unwrap_or(slug),
level: heading.level as u8,
classes: heading.classes,
});
}
parsed.set_headings(headings);
parsed
}
fn find_matching_heading_end(events: &[Event], start_index: usize) -> Option<usize> {
events[start_index + 1..]
.iter()
.position(|event| matches!(event, Event::End(TagEnd::Heading(_))))
.map(|offset| start_index + 1 + offset)
}
fn find_matching_image_end(events: &[Event], start_index: usize) -> Option<usize> {
events[start_index + 1..]
.iter()
.position(|event| matches!(event, Event::End(TagEnd::Image)))
.map(|offset| start_index + 1 + offset)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_markdown_rendering() {
let markdown = r#"# Hello, world!
This is a **bold** text.
## Subheading
More content here."#;
let html = render_markdown(markdown, None, None, None);
assert!(html.contains("<h1"));
assert!(html.contains("<h2"));
assert!(html.contains("</h1>"));
assert!(html.contains("</h2>"));
assert!(html.contains("<strong>bold</strong>"));
assert!(html.contains("Hello, world!"));
assert!(html.contains("Subheading"));
}
#[test]
fn test_rendering_with_empty_components() {
let options = MarkdownOptions {
components: MarkdownComponents::new(),
..Default::default()
};
let markdown = r#"# Hello, world!"#;
let html = render_markdown(markdown, Some(&options), None, None);
let default_html = render_markdown(markdown, None, None, None);
assert_eq!(html, default_html);
}
#[test]
fn test_default_heading_behavior_with_and_without_options() {
let markdown = r#"# Main Title
## Subheading
### Another Level"#;
let html_no_options = render_markdown(markdown, None, None, None);
let options_no_heading = MarkdownOptions {
components: MarkdownComponents::new(),
..Default::default()
};
let html_with_empty_options =
render_markdown(markdown, Some(&options_no_heading), None, None);
assert_eq!(html_no_options, html_with_empty_options);
assert!(html_no_options.contains("id=\""));
assert!(html_no_options.contains("<h1"));
assert!(html_no_options.contains("<h2"));
assert!(html_no_options.contains("<h3"));
assert!(html_with_empty_options.contains("id=\""));
}
fn create_test_shortcodes() -> MarkdownShortcodes {
let mut shortcodes = MarkdownShortcodes::new();
shortcodes.register("simple", |_args, _| "SIMPLE_OUTPUT".to_string());
shortcodes.register("greet", |args, _| {
let name = args.get_str("name").unwrap_or("World");
format!("Hello, {}!", name)
});
shortcodes.register("date", |args, _| {
let format = args.get_str("format").unwrap_or("default");
format!("DATE[{}]", format)
});
shortcodes.register("highlight", |args, _| {
let lang = args.get_str("lang").unwrap_or("text");
let body = args.get_str("body").unwrap_or("");
format!("<code class=\"lang-{}\">{}</code>", lang, body)
});
shortcodes.register("alert", |args, _| {
let alert_type = args.get_str("type").unwrap_or("info");
let body = args.get_str("body").unwrap_or("");
format!("<div class=\"alert alert-{}\">{}</div>", alert_type, body)
});
shortcodes.register("section", |args, _| {
let title = args.get_str("title").unwrap_or("");
let body = args.get_str("body").unwrap_or("");
if title.is_empty() {
format!("<section>{}</section>", body)
} else {
format!("<section data-title=\"{}\">{}</section>", title, body)
}
});
shortcodes
}
#[test]
fn test_markdown_with_shortcodes_basic() {
let shortcodes = create_test_shortcodes();
let options = MarkdownOptions {
shortcodes,
..Default::default()
};
let markdown = "# {{ greet name=Title /}}\n\nHello {{ simple /}}!";
let html = render_markdown(markdown, Some(&options), None, None);
assert!(html.contains("<h1"));
assert!(html.contains("Hello, Title!"));
assert!(html.contains("Hello SIMPLE_OUTPUT!"));
}
#[test]
fn test_markdown_with_shortcodes_in_headings() {
let shortcodes = create_test_shortcodes();
let options = MarkdownOptions {
shortcodes,
..Default::default()
};
let markdown = r#"# {{ greet name=Main /}}
## Section {{ date format=short /}}
### {{ simple /}} Chapter"#;
let html = render_markdown(markdown, Some(&options), None, None);
assert!(html.contains("<h1"));
assert!(html.contains("Hello, Main!"));
assert!(html.contains("<h2"));
assert!(html.contains("Section DATE[short]"));
assert!(html.contains("<h3"));
assert!(html.contains("SIMPLE_OUTPUT Chapter"));
}
#[test]
fn test_markdown_with_shortcodes_in_emphasis() {
let shortcodes = create_test_shortcodes();
let options = MarkdownOptions {
shortcodes,
..Default::default()
};
let markdown = "*{{ greet name=Italic /}}* and **{{ simple /}}**";
let html = render_markdown(markdown, Some(&options), None, None);
assert!(html.contains("<em>Hello, Italic!</em>"));
assert!(html.contains("<strong>SIMPLE_OUTPUT</strong>"));
}
#[test]
fn test_markdown_with_shortcodes_in_lists() {
let shortcodes = create_test_shortcodes();
let options = MarkdownOptions {
shortcodes,
..Default::default()
};
let markdown = r#"1. {{ greet name=First /}}
2. {{ simple /}}
3. {{ date format=iso /}}"#;
let html = render_markdown(markdown, Some(&options), None, None);
assert!(html.contains("<ol>"));
assert!(html.contains("<li>Hello, First!</li>"));
assert!(html.contains("<li>SIMPLE_OUTPUT</li>"));
assert!(html.contains("<li>DATE[iso]</li>"));
}
#[test]
fn test_markdown_with_shortcodes_in_tables() {
let shortcodes = create_test_shortcodes();
let options = MarkdownOptions {
shortcodes,
..Default::default()
};
let markdown = r#"| Name | Greeting |
|------|----------|
| Alice | {{ greet name=Alice /}} |
| Bob | {{ simple /}} |"#;
let html = render_markdown(markdown, Some(&options), None, None);
assert!(html.contains("<table>"));
assert!(html.contains("<th>Name</th>"));
assert!(html.contains("<th>Greeting</th>"));
assert!(html.contains("<td>Alice</td>"));
assert!(html.contains("<td>Hello, Alice!</td>"));
assert!(html.contains("<td>Bob</td>"));
assert!(html.contains("<td>SIMPLE_OUTPUT</td>"));
}
#[test]
fn test_markdown_with_shortcodes_in_blockquotes() {
let shortcodes = create_test_shortcodes();
let options = MarkdownOptions {
shortcodes,
..Default::default()
};
let markdown = r#"> {{ greet name=Quote /}}
>
> {{ simple /}}"#;
let html = render_markdown(markdown, Some(&options), None, None);
assert!(html.contains("<blockquote>"));
assert!(html.contains("Hello, Quote!"));
assert!(html.contains("SIMPLE_OUTPUT"));
}
#[test]
fn test_markdown_with_shortcodes_in_code_blocks() {
let shortcodes = create_test_shortcodes();
let options = MarkdownOptions {
shortcodes,
..Default::default()
};
let markdown = r#"```rust
fn main() {
println!("{{ greet name=Rust /}}");
// {{ simple /}}
}
```"#;
let html = render_markdown(markdown, Some(&options), None, None);
assert!(html.contains("<pre"));
assert!(html.contains("<code"));
assert!(html.contains("Hello, Rust!"));
assert!(html.contains("SIMPLE_OUTPUT"));
}
#[test]
fn test_markdown_with_shortcodes_in_links() {
let shortcodes = create_test_shortcodes();
let options = MarkdownOptions {
shortcodes,
..Default::default()
};
let markdown = r#"[{{ greet name=Link /}}](https://example.com "{{ simple /}}")
"#;
let html = render_markdown(markdown, Some(&options), None, None);
assert!(html.contains("<a href=\"https://example.com\""));
assert!(html.contains("title=\"SIMPLE_OUTPUT\""));
assert!(html.contains(">Hello, Link!</a>"));
assert!(html.contains("<img src=\"image.jpg\""));
assert!(html.contains("alt=\"Hello, Alt!\""));
assert!(html.contains("title=\"DATE[title]\""));
}
#[test]
fn test_markdown_with_block_shortcodes() {
let shortcodes = create_test_shortcodes();
let options = MarkdownOptions {
shortcodes,
..Default::default()
};
let markdown = r#"{{ highlight lang=rust }}
fn main() {
println!("{{ greet name=World /}}");
}
{{ /highlight }}"#;
let html = render_markdown(markdown, Some(&options), None, None);
assert!(html.contains("<code class=\"lang-rust\">"));
assert!(html.contains("Hello, World!"));
assert!(html.contains("</code>"));
}
#[test]
fn test_markdown_with_nested_shortcodes() {
let shortcodes = create_test_shortcodes();
let options = MarkdownOptions {
shortcodes,
..Default::default()
};
let markdown = r#"{{ alert type=warning }}
## {{ greet name=Alert /}}
{{ simple /}} content here.
{{ /alert }}"#;
let html = render_markdown(markdown, Some(&options), None, None);
assert!(html.contains("<div class=\"alert alert-warning\">"));
assert!(html.contains("## Hello, Alert!"));
assert!(html.contains("SIMPLE_OUTPUT content"));
assert!(html.contains("</div>"));
}
#[test]
fn test_markdown_with_deeply_nested_shortcodes() {
let shortcodes = create_test_shortcodes();
let options = MarkdownOptions {
shortcodes,
..Default::default()
};
let markdown = r#"{{ section title=Main }}
# {{ greet name=Header /}}
{{ alert type=info }}
**{{ greet name=Bold /}}** and *{{ simple /}}*
{{ /alert }}
{{ /section }}"#;
let html = render_markdown(markdown, Some(&options), None, None);
assert!(html.contains("<section data-title=\"Main\">"));
assert!(html.contains("# Hello, Header!"));
assert!(html.contains("<div class=\"alert alert-info\">"));
assert!(html.contains("**Hello, Bold!** and *SIMPLE_OUTPUT*"));
assert!(html.contains("</div>"));
assert!(html.contains("</section>"));
}
#[test]
fn test_markdown_with_shortcodes_in_frontmatter() {
let shortcodes = create_test_shortcodes();
let options = MarkdownOptions {
shortcodes,
..Default::default()
};
let markdown = r#"---
title: {{ greet name=Blog /}}
date: {{ date format=iso /}}
tags: [{{ simple /}}, {{ greet name=Tutorial /}}]
---
# {{ greet name=Content /}}
Welcome to {{ simple /}}!"#;
let html = render_markdown(markdown, Some(&options), None, None);
assert!(!html.contains("---"));
assert!(!html.contains("title:"));
assert!(html.contains("<h1"));
assert!(html.contains("Hello, Content!"));
assert!(html.contains("Welcome to SIMPLE_OUTPUT!"));
}
#[test]
fn test_markdown_with_task_lists_and_shortcodes() {
let shortcodes = create_test_shortcodes();
let options = MarkdownOptions {
shortcodes,
..Default::default()
};
let markdown = r#"- [x] {{ greet name=Done /}}
- [ ] {{ simple /}}
- [ ] {{ date format=todo /}}"#;
let html = render_markdown(markdown, Some(&options), None, None);
assert!(html.contains("<ul>"));
assert!(html.contains("type=\"checkbox\""));
assert!(html.contains("checked=\"\""));
assert!(html.contains("Hello, Done!"));
assert!(html.contains("SIMPLE_OUTPUT"));
assert!(html.contains("DATE[todo]"));
}
#[test]
fn test_markdown_with_strikethrough_and_shortcodes() {
let shortcodes = create_test_shortcodes();
let options = MarkdownOptions {
shortcodes,
..Default::default()
};
let markdown = "~~{{ greet name=Deleted /}}~~ and {{ simple /}}";
let html = render_markdown(markdown, Some(&options), None, None);
assert!(html.contains("<del>Hello, Deleted!</del>"));
assert!(html.contains("and SIMPLE_OUTPUT"));
}
#[test]
fn test_markdown_real_world_blog_post_with_shortcodes() {
let shortcodes = create_test_shortcodes();
let options = MarkdownOptions {
shortcodes,
..Default::default()
};
let markdown = r#"---
title: {{ greet name=BlogPost /}}
date: {{ date format=iso /}}
---
# {{ greet name=Reader /}}!
Welcome to my blog about **{{ simple /}}**.
## What we'll cover
1. {{ greet name=Introduction /}}
2. {{ simple /}} basics
3. Advanced {{ greet name=Techniques /}}
{{ alert type=info }}
💡 **Tip**: Remember {{ greet name=This /}}!
{{ /alert }}
### Code Example
{{ highlight lang=rust }}
fn main() {
println!("{{ greet name=World /}}!");
}
{{ /highlight }}
### Task List
- [x] {{ greet name=Setup /}}
- [ ] {{ simple /}}
- [ ] {{ greet name=Deploy /}}
> "{{ greet name=Quote /}}" - *{{ simple /}}*
Check out [this link](https://example.com "{{ greet name=Title /}}")!"#;
let html = render_markdown(markdown, Some(&options), None, None);
assert!(html.contains("<h1"));
assert!(html.contains("Hello, Reader!"));
assert!(html.contains("<strong>SIMPLE_OUTPUT</strong>"));
assert!(html.contains("<h2"));
assert!(html.contains("<ol>"));
assert!(html.contains("Hello, Introduction!"));
assert!(html.contains("<div class=\"alert alert-info\">"));
assert!(html.contains("Remember Hello, This!"));
assert!(html.contains("<code class=\"lang-rust\">"));
assert!(html.contains("Hello, World!"));
assert!(html.contains("<ul>"));
assert!(html.contains("type=\"checkbox\""));
assert!(html.contains("Hello, Setup!"));
assert!(html.contains("<blockquote>"));
assert!(html.contains("Hello, Quote!"));
assert!(html.contains("<a href=\"https://example.com\""));
assert!(html.contains("title=\"Hello, Title!\""));
}
#[test]
fn test_markdown_with_math_and_shortcodes() {
let shortcodes = create_test_shortcodes();
let options = MarkdownOptions {
shortcodes,
..Default::default()
};
let markdown = r#"Inline math with {{ simple /}}: $x = {{ greet name=Variable /}}$
Block math:
$$
{{ greet name=Equation /}}
$$"#;
let html = render_markdown(markdown, Some(&options), None, None);
assert!(html.contains("SIMPLE_OUTPUT"));
assert!(html.contains("Hello, Variable!"));
assert!(html.contains("Hello, Equation!"));
}
}