use lazy_static::lazy_static; use markdown_it::generics::inline::full_link; use markdown_it::parser::block::{BlockRule, BlockState}; use markdown_it::parser::core::Root; use markdown_it::parser::inline::{InlineRule, InlineState}; use markdown_it::{MarkdownIt, Node, NodeValue, Renderer}; use maud::{Markup, PreEscaped, Render, html}; use page::{Page, PageMetadata}; use std::str::FromStr; use crate::components::fa::FAIcon; use crate::components::mangle::{MangledBetaEmail, MangledGoogleEmail}; use crate::components::misc::Backlinks; lazy_static! { static ref MdParser: MarkdownIt = { let mut md = markdown_it::MarkdownIt::new(); { use markdown_it::plugins::cmark::*; inline::newline::add(&mut md); inline::escape::add(&mut md); inline::backticks::add(&mut md); inline::emphasis::add(&mut md); // Replaced with smart links //inline::link::add(&mut md); full_link::add::(&mut md, |href, title| { Node::new(SmartLink { url: href.unwrap_or_default(), title, }) }); inline::image::add(&mut md); inline::autolink::add(&mut md); inline::entity::add(&mut md); block::code::add(&mut md); block::fence::add(&mut md); block::blockquote::add(&mut md); block::hr::add(&mut md); block::list::add(&mut md); block::reference::add(&mut md); block::heading::add(&mut md); block::lheading::add(&mut md); block::paragraph::add(&mut md); } markdown_it::plugins::html::add(&mut md); md.block.add_rule::().before_all(); md.block.add_rule::().before_all(); md.inline.add_rule::(); md.inline.add_rule::(); md.inline.add_rule::(); md }; } pub struct Markdown<'a>(pub &'a str); impl Render for Markdown<'_> { fn render(&self) -> Markup { let md = Self::parse(self.0); let html = md.render(); return PreEscaped(html); } } impl Markdown<'_> { pub fn parse(md_str: &str) -> Node { MdParser.parse(md_str) } } // // MARK: helpers // /// Try to read page metadata from a markdown file's frontmatter. /// - returns `none` if there is no frontmatter /// - returns an error if we fail to parse frontmatter pub fn meta_from_markdown(root_node: &Node) -> Result, toml::de::Error> { root_node .children .first() .and_then(|x| x.cast::()) .map(|x| toml::from_str::(&x.content)) .map_or(Ok(None), |v| v.map(Some)) } pub fn page_from_markdown(md: impl Into, default_image: Option) -> Page { let md: String = md.into(); let md = Markdown::parse(&md); let mut meta = meta_from_markdown(&md) .unwrap_or(Some(PageMetadata { title: "Invalid frontmatter!".into(), ..Default::default() })) .unwrap_or_default(); if meta.image.is_none() { meta.image = default_image } let html = PreEscaped(md.render()); Page { meta, generate_html: Box::new(move |page, _| { let html = html.clone(); Box::pin(async move { html! { @if let Some(slug) = &page.meta.slug { (Backlinks(&[("/", "home")], slug)) } (html) } }) }), ..Default::default() } } // // MARK: extensions // // // MARK: smart link // #[derive(Debug)] pub struct SmartLink { pub url: String, pub title: Option, } impl NodeValue for SmartLink { fn render(&self, node: &Node, fmt: &mut dyn Renderer) { let mut attrs = node.attrs.clone(); attrs.push(("href", self.url.clone())); if let Some(title) = &self.title { attrs.push(("title", title.clone())); } let external = !(self.url.starts_with(".") || self.url.starts_with("/")); // Open external links in a new tab if external { attrs.push(("target", "_blank".into())); attrs.push(("rel", "noopener noreferrer".into())); } fmt.open("a", &attrs); fmt.contents(&node.children); fmt.close("a"); } } // // MARK: emote // #[derive(Debug)] pub struct InlineEmote(String); impl NodeValue for InlineEmote { fn render(&self, _node: &Node, fmt: &mut dyn Renderer) { fmt.text_raw(self.0.as_str()); } } impl InlineRule for InlineEmote { const MARKER: char = ':'; fn run(state: &mut InlineState<'_, '_>) -> Option<(Node, usize)> { let input = &state.src[state.pos..state.pos_max]; if !input.starts_with(':') { return None; } let end_idx = input[1..].find(':')? + 1; let code = &input[1..end_idx]; let mut emote = None; if emote.is_none() && let Some(code) = code.strip_prefix("fa-") { emote = FAIcon::from_str(code).ok().map(|x| x.render().0) } if emote.is_none() { emote = emojis::get_by_shortcode(code).map(|x| x.to_string()); } Some((Node::new(InlineEmote(emote?)), end_idx + 1)) } } // // MARK: mdx // #[derive(Debug)] pub struct InlineMdx(String); impl NodeValue for InlineMdx { fn render(&self, node: &Node, fmt: &mut dyn Renderer) { if mdx_style(&self.0, node, fmt) { return; } if mdx_include(&self.0, node, fmt) { return; } fmt.open("code", &[]); fmt.text(&self.0); fmt.close("code"); } } impl InlineRule for InlineMdx { const MARKER: char = '{'; fn run(state: &mut InlineState<'_, '_>) -> Option<(Node, usize)> { let input = &state.src[state.pos..state.pos_max]; if !input.starts_with('{') { return None; } let mut balance = 1; let mut end = 1; for i in input[1..].bytes() { match i { b'}' => balance -= 1, b'{' => balance += 1, _ => {} } if balance == 0 { break; } end += 1; } if balance != 0 { return None; } let content = &input[1..end]; Some((Node::new(InlineMdx(content.to_owned())), content.len() + 2)) } } fn mdx_style(mdx: &str, _node: &Node, fmt: &mut dyn Renderer) -> bool { // Parse inside of mdx: `color(value, "text")` let mdx = mdx .trim() .trim_start_matches('{') .trim_end_matches('}') .trim(); // Find the function name (everything before the opening parenthesis) let paren_pos = match mdx.find('(') { Some(x) => x, None => return false, }; if mdx[..paren_pos].trim() != "color" { return false; }; // Find matching closing parenthesis let skip = paren_pos + 1; let mut balance = 1; let mut end = skip; for i in mdx[skip..].bytes() { match i { b')' => balance -= 1, b'(' => balance += 1, _ => {} } if balance == 0 { break; } end += 1; } if balance != 0 { return false; } let args = mdx[skip..end].trim(); // Parse arguments: should be "value, text" or "value, \"text\"" let comma_pos = match args.find(',') { Some(x) => x, None => return false, }; let value = args[..comma_pos].trim(); let text = args[comma_pos + 1..].trim(); // Strip quotes from text if present let text = if (text.starts_with('"') && text.ends_with('"')) || (text.starts_with('\'') && text.ends_with('\'')) { &text[1..text.len() - 1] } else { text }; let mut style_str = String::new(); if value.starts_with("#") { style_str.push_str("color:"); style_str.push_str(value); style_str.push(';'); } else if value.starts_with("--") { style_str.push_str("color:var("); style_str.push_str(value); style_str.push_str(");"); } else { style_str.push_str("color:"); style_str.push_str(value); style_str.push(';'); } fmt.open("span", &[("style", style_str)]); fmt.text(text); fmt.close("span"); return true; } fn mdx_include(mdx: &str, _node: &Node, fmt: &mut dyn Renderer) -> bool { // Parse inside of mdx: `include()` let args = { let mdx = mdx .trim() .trim_start_matches('{') .trim_end_matches('}') .trim(); if !mdx.starts_with("include(") { return false; } let skip = 8; let mut balance = 1; let mut end = skip; for i in mdx[skip..].bytes() { match i { b')' => balance -= 1, b'(' => balance += 1, _ => {} } if balance == 0 { break; } end += 1; } if balance != 0 { return false; } let args = mdx[skip..end].trim(); let trail = mdx[end + 1..].trim(); if !trail.is_empty() { return false; } args }; let str = match args { "email_beta" => MangledBetaEmail {}.render().0, "email_goog" => MangledGoogleEmail {}.render().0, _ => return false, }; fmt.text_raw(&str); return true; } // // MARK: yaml frontmatter // #[derive(Debug)] pub struct YamlFrontMatter { #[expect(dead_code)] pub content: String, } impl NodeValue for YamlFrontMatter { fn render(&self, _node: &Node, _fmt: &mut dyn Renderer) {} } impl BlockRule for YamlFrontMatter { fn run(state: &mut BlockState<'_, '_>) -> Option<(Node, usize)> { // check the parent is the document Root if !state.node.is::() { return None; } // check we are on the first line of the document if state.line != 0 { return None; } // check line starts with opening dashes let opening = state .get_line(state.line) .chars() .take_while(|c| *c == '-') .collect::(); if !opening.starts_with("---") { return None; } // Search for the end of the block let mut next_line = state.line; loop { next_line += 1; if next_line >= state.line_max { return None; } let line = state.get_line(next_line); if line.starts_with(&opening) { break; } } let (content, _) = state.get_lines(state.line + 1, next_line, 0, true); Some((Node::new(YamlFrontMatter { content }), next_line + 1)) } } // // MARK: toml frontmatter // #[derive(Debug)] pub struct TomlFrontMatter { pub content: String, } impl NodeValue for TomlFrontMatter { fn render(&self, _node: &Node, _fmt: &mut dyn Renderer) {} } impl BlockRule for TomlFrontMatter { fn run(state: &mut BlockState<'_, '_>) -> Option<(Node, usize)> { if !state.node.is::() { return None; } if state.line != 0 { return None; } let opening = state .get_line(state.line) .chars() .take_while(|c| *c == '+') .collect::(); if !opening.starts_with("+++") { return None; } let mut next_line = state.line; loop { next_line += 1; if next_line >= state.line_max { return None; } let line = state.get_line(next_line); if line.starts_with(&opening) { break; } } let (content, _) = state.get_lines(state.line + 1, next_line, 0, true); Some((Node::new(TomlFrontMatter { content }), next_line + 1)) } }