Files
webpage/crates/service/service-webpage/src/components/md.rs
2025-11-05 09:55:03 -08:00

433 lines
8.3 KiB
Rust

use lazy_static::lazy_static;
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();
markdown_it::plugins::cmark::add(&mut md);
markdown_it::plugins::html::add(&mut md);
md.block.add_rule::<YamlFrontMatter>().before_all();
md.block.add_rule::<TomlFrontMatter>().before_all();
md.inline.add_rule::<InlineEmote>();
md.inline.add_rule::<InlineEmote>();
md.inline.add_rule::<InlineMdx>();
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<Option<PageMetadata>, toml::de::Error> {
root_node
.children
.first()
.and_then(|x| x.cast::<TomlFrontMatter>())
.map(|x| toml::from_str::<PageMetadata>(&x.content))
.map_or(Ok(None), |v| v.map(Some))
}
pub fn page_from_markdown(md: impl Into<String>, default_image: Option<String>) -> 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: 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: `style(<style>) <content>`
let (style, content) = {
let mdx = mdx.trim();
if !mdx.starts_with("style(") {
return false;
}
let skip = 6;
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 style = mdx[skip..end].trim();
let content = mdx[end + 1..].trim();
(style, content)
};
let mut style_str = String::new();
for kv in style.split(";") {
let mut s = kv.split(":");
let k = s.next();
let v = s.next();
if k.is_none() || v.is_none() || s.next().is_some() {
return false;
}
#[expect(clippy::unwrap_used)] // Checked previously
let k = k.unwrap().trim();
#[expect(clippy::unwrap_used)] // Checked previously
let v = v.unwrap().trim();
match k {
"color" => {
style_str.push_str("color:");
style_str.push_str(v);
style_str.push(';');
}
"color_var" => {
style_str.push_str("color:var(--");
style_str.push_str(v);
style_str.push_str(");");
}
_ => continue,
}
}
// Only works with text, could be reworked to do basic md styling
// (italics, bold, tab, code)
fmt.open("span", &[("style", style_str)]);
fmt.text(content);
fmt.close("span");
return true;
}
fn mdx_include(mdx: &str, _node: &Node, fmt: &mut dyn Renderer) -> bool {
// Parse inside of mdx: `include(<args>)`
let args = {
let mdx = mdx.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::<Root>() {
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::<String>();
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::<Root>() {
return None;
}
if state.line != 0 {
return None;
}
let opening = state
.get_line(state.line)
.chars()
.take_while(|c| *c == '+')
.collect::<String>();
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))
}
}