use crate::config::ParserOptions;
use crate::highlight::MarmiteHighlighter;
use crate::re;
use crate::site::Data;
use comrak::{
markdown_to_html, markdown_to_html_with_plugins, options::BrokenLinkReference,
options::Plugins, options::RenderPlugins, Options, ResolvedReference,
};
use frontmatter_gen::{detect_format, extract_raw_frontmatter, parse, Frontmatter};
use log::warn;
use regex::Regex;
use std::fmt::Write as _;
use std::fs;
use std::path::Path;
use std::sync::Arc;
use url::Url;
use urlencoding::decode as urldecode;
pub fn append_references(content: &str, references_path: &Path) -> String {
if references_path.exists() {
let references = fs::read_to_string(references_path).unwrap_or_default();
format!("{content}\n\n{references}")
} else {
content.to_string()
}
}
pub fn get_links_to(html: &str) -> Option<Vec<String>> {
let mut result = Vec::new();
let re = Regex::new(re::CAPTURE_SLUG_ANCHOR_FROM_HREF).expect("Links regex should compile");
for cap in re.captures_iter(html) {
if let Some(m) = cap.get(1) {
let href = m.as_str();
if !href.starts_with("http") {
let page = href.trim_start_matches("./").to_string();
let heading = cap.get(2).map_or("", |h| h.as_str());
result.push(format!("{page}{heading}").to_string());
}
}
}
if result.is_empty() {
return None;
}
Some(result)
}
#[allow(clippy::needless_pass_by_value)]
fn warn_broken_link(link_ref: BrokenLinkReference) -> Option<ResolvedReference> {
let original = link_ref.original;
let is_allowed = original
.starts_with("http") || original.starts_with('!') || original.starts_with('#') ||original.starts_with('^') || original.starts_with('/') || (original.len() == 1 && original.chars().next().is_some_and(|c| !c.is_ascii_digit())) || original.is_empty(); if !is_allowed {
warn!("Reference missing: [{original}] - add '[{original}]: url' to the end of your content file or to the '_references.md' file.");
}
None
}
pub fn get_table_of_contents_from_html(html: &str) -> String {
let re = Regex::new(re::CAPTURE_LEVEL_ANCHOR_TEXT_FROM_H_TAG)
.expect("Table of contents regex should compile");
let mut toc = String::new();
let mut last_level = 0;
for cap in re.captures_iter(html) {
let level = cap.get(1).map_or(0, |m| m.as_str().parse().unwrap_or(0));
let title = cap.get(3).map_or("", |m| m.as_str());
let slug = cap.get(2).map_or_else(
|| format!("#{}", slug::slugify(title)),
|m| m.as_str().to_string(),
);
match level.cmp(&last_level) {
std::cmp::Ordering::Greater => {
for _ in last_level..level {
toc.push_str("<ul>\n");
}
}
std::cmp::Ordering::Less => {
for _ in level..last_level {
toc.push_str("</ul>\n");
}
}
std::cmp::Ordering::Equal => {}
}
let _ = writeln!(toc, "<li><a href=\"{slug}\">{title}</a></li>");
last_level = level;
}
for _ in 0..last_level {
toc.push_str("</ul>\n");
}
toc
}
pub fn get_html_with_options(
markdown: &str,
parser_options: &ParserOptions,
highlighter: Option<&MarmiteHighlighter>,
) -> String {
let mut options = Options::default();
options.render.figure_with_caption = parser_options.render.figure_with_caption;
options.render.ignore_empty_links = parser_options.render.ignore_empty_links;
options.render.r#unsafe = parser_options.render.unsafe_;
options.parse.broken_link_callback = Some(Arc::new(warn_broken_link)); options.parse.relaxed_tasklist_matching = parser_options.parse.relaxed_tasklist_matching;
options.extension.alerts = parser_options.extension.alerts;
options.extension.autolink = parser_options.extension.autolink;
options.extension.description_lists = parser_options.extension.description_lists;
options.extension.footnotes = parser_options.extension.footnotes;
options.extension.greentext = parser_options.extension.greentext;
options.extension.header_id_prefix = Some(String::new());
options.extension.multiline_block_quotes = parser_options.extension.multiline_block_quotes;
options.extension.tagfilter = parser_options.extension.tagfilter;
options.extension.shortcodes = parser_options.extension.shortcodes;
options.extension.spoiler = parser_options.extension.spoiler;
options.extension.strikethrough = parser_options.extension.strikethrough;
options.extension.table = parser_options.extension.table;
options.extension.tasklist = parser_options.extension.tasklist;
options.extension.underline = parser_options.extension.underline;
options.extension.wikilinks_title_before_pipe =
parser_options.extension.wikilinks_title_before_pipe;
options.extension.wikilinks_title_after_pipe =
parser_options.extension.wikilinks_title_after_pipe;
let rendered = if let Some(hl) = highlighter {
let render_plugins = RenderPlugins {
codefence_syntax_highlighter: Some(hl),
..RenderPlugins::default()
};
let plugins = Plugins {
render: render_plugins,
};
markdown_to_html_with_plugins(markdown, &options, &plugins)
} else {
markdown_to_html(markdown, &options)
};
fix_internal_links(&rendered)
}
pub fn fix_internal_links(html: &str) -> String {
let re = Regex::new(re::CAPTURE_LINK_AND_TEXT_FROM_A_TAG)
.expect("Fix internal links regex should compile");
re.replace_all(html, |caps: ®ex::Captures| {
let link = caps.get(0).map_or("", |m| m.as_str());
let href = caps.get(1).map_or("", |m| m.as_str());
let text = caps.get(2).map_or("", |m| m.as_str());
let media_extensions = [
".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".avif", ".bmp", ".tiff", ".tif",
".ico", ".pdf", ".mp4", ".mov", ".avi", ".mkv", ".webm", ".mp3", ".wav", ".ogg",
".flac", ".zip", ".tar", ".gz", ".7z", ".rar", ".doc", ".docx", ".xls", ".xlsx",
".ppt", ".pptx", ".txt", ".csv", ".json", ".xml", ".yaml", ".yml", ".toml",
];
let href_lower = href.to_lowercase();
let is_media_file = media_extensions.iter().any(|ext| href_lower.ends_with(ext));
if link.contains("class=\"anchor\"")
|| link.contains("data-footnote-ref")
|| link.contains("footnote-backref")
|| link.starts_with('/')
|| href.starts_with('.')
|| is_media_file
{
return link.to_string();
}
if let Ok(url) = Url::parse(href) {
if !url.scheme().is_empty() {
return link.to_string();
}
}
let new_href = if let Ok(parsed) = Url::parse(&format!("m://m/{href}")) {
let raw_path = parsed
.path()
.trim_start_matches('/')
.trim_end_matches(".md")
.trim_end_matches(".html");
let decoded_path = urldecode(raw_path).unwrap_or_else(|_| raw_path.into());
let path = slug::slugify(&*decoded_path);
let fragment = match parsed.fragment() {
Some(f) => {
let decoded_f = urldecode(f).unwrap_or_else(|_| f.into());
slug::slugify(&*decoded_f)
}
None => String::new(),
};
let mut new_href = String::new();
if !path.is_empty() {
let _ = write!(new_href, "{path}.html");
}
if !fragment.is_empty() {
let _ = write!(new_href, "#{fragment}");
}
new_href
} else {
href.to_string()
};
let new_text = text
.trim_start_matches('#')
.trim_end_matches(".md")
.trim_end_matches(".html")
.replace('#', " > ");
link.replace(&format!("href=\"{href}\""), &format!("href=\"{new_href}\""))
.replace(&format!(">{text}</a>"), &format!(">{new_text}</a>"))
})
.to_string()
}
fn decode_html_entities(text: &str) -> String {
text.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("'", "'")
}
fn find_content_by_title(title: &str, site_data: &Data) -> Option<String> {
let title_lower = title.to_lowercase();
for content in &site_data.posts {
if content.title.to_lowercase() == title_lower {
return Some(format!("{}.html", content.slug));
}
}
for content in &site_data.pages {
if content.title.to_lowercase() == title_lower {
return Some(format!("{}.html", content.slug));
}
}
None
}
pub fn fix_wikilinks(html: &str, site_data: &Data) -> String {
let re =
Regex::new(re::CAPTURE_WIKILINK_HREF_AND_TITLE).expect("Wikilink regex should compile");
re.replace_all(html, |caps: ®ex::Captures| {
let original_link = caps.get(0).map_or("", |m| m.as_str());
let original_href = caps.get(1).map_or("", |m| m.as_str());
let link_title = caps.get(2).map_or("", |m| m.as_str());
let decoded_title = decode_html_entities(link_title);
if let Some(proper_href) = find_content_by_title(&decoded_title, site_data) {
original_link.replace(
&format!("href=\"{original_href}\""),
&format!("href=\"{proper_href}\""),
)
} else {
original_link.to_string()
}
})
.to_string()
}
pub fn parse_front_matter(content: &str) -> Result<(Frontmatter, &str), String> {
let content = content.trim_start_matches('\n');
let has_frontmatter =
content.starts_with("---") || content.starts_with("+++") || content.starts_with('{');
if !has_frontmatter {
return Ok((Frontmatter::new(), content));
}
extract_fm_content(content)
}
pub fn extract_fm_content(content: &str) -> Result<(Frontmatter, &str), String> {
let (raw_frontmatter, remaining_content) = extract_raw_frontmatter(content)?;
let format = detect_format(raw_frontmatter)?;
let frontmatter = parse(raw_frontmatter, format)?;
Ok((frontmatter, remaining_content))
}
#[cfg(test)]
#[path = "tests/parser.rs"]
mod tests;