433 lines
8.3 KiB
Rust
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))
|
|
}
|
|
}
|