Footnotes
Some checks failed
CI / Check typos (push) Successful in 8s
CI / Check links (push) Failing after 12s
CI / Clippy (push) Successful in 54s
CI / Build and test (push) Successful in 1m11s
CI / Build container (push) Successful in 52s
CI / Deploy on waypoint (push) Successful in 44s

This commit is contained in:
2025-11-06 22:20:01 -08:00
parent d508a0d031
commit a3ff195de9
11 changed files with 579 additions and 558 deletions

View File

@@ -40,7 +40,7 @@ pub struct FootnoteRefAnchor {
impl NodeValue for FootnoteRefAnchor {
fn render(&self, _: &Node, fmt: &mut dyn markdown_it::Renderer) {
for ref_id in self.ref_ids.iter() {
fmt.text(" ");
fmt.text_raw(" ");
fmt.open(
"a",
&[
@@ -49,7 +49,7 @@ impl NodeValue for FootnoteRefAnchor {
],
);
// # ↩ with escape code to prevent display as Apple Emoji on iOS
fmt.text("\u{21a9}\u{FE0E}");
fmt.text_raw("back \u{21a9}\u{FE0E}");
fmt.close("a");
}
}

View File

@@ -1,6 +1,9 @@
#![expect(unused_imports)]
#![expect(unused_crate_dependencies)]
use std::path::PathBuf;
use testing::fixture;
/*
#[fixture("tests/fixtures/[!_]*.md")]
fn test_html(file: PathBuf) {
let f = md_dev::read_fixture_file(file);
@@ -13,3 +16,4 @@ fn test_html(file: PathBuf) {
md_dev::assert_no_diff(f, &actual);
}
*/

View File

@@ -15,10 +15,12 @@ assetserver = { workspace = true }
toolbox = { workspace = true }
page = { workspace = true }
md-footnote = { workspace = true }
markdown-it = { workspace = true }
axum = { workspace = true }
tracing = { workspace = true }
maud = { workspace = true }
markdown-it = { workspace = true }
emojis = { workspace = true }
strum = { workspace = true }
chrono = { workspace = true }

View File

@@ -43,33 +43,42 @@ a:hover {
transition: 150ms;
}
footer {
font-size: 1.4rem;
clear: both;
opacity: 0.5;
}
footer {
text-align: left
}
.footnote-definition {
margin: 0 0 0 2rem;
}
.footnote-definition-label {
color: var(--metaColor);
}
.footnote-definition p {
.footnote-item p {
display: inline;
padding: 0 0 0 1rem;
}
hr.footnotes-sep {
margin: 5rem 0 0 0;
}
.footnote-ref > a {
padding: 0 2pt 0.8rem 2pt !important;
}
a.footnote-backref, .footnote-ref > a
{
color: var(--metaColor);
padding: 0 2pt 0 2pt;
}
a.footnote-backref:hover,
.footnote-ref > a:hover
{
color: var(--bgColor);
background-color: var(--metaColor);
}
.footContainer {
display: flex;
flex-wrap: wrap;

View File

@@ -1,541 +0,0 @@
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, RequestContext};
use std::str::FromStr;
use crate::components::fa::FAIcon;
use crate::components::mangle::{MangledBetaEmail, MangledGoogleEmail};
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::<false>(&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::<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, ctx| {
let html = html.clone();
Box::pin(async move {
html! {
@if let Some(backlinks) = backlinks(page, ctx) {
(backlinks)
}
(html)
}
})
}),
..Default::default()
}
}
pub fn backlinks(page: &Page, ctx: &RequestContext) -> Option<Markup> {
let mut last = None;
let mut backlinks = vec![("/", "home")];
if page.meta.backlinks.unwrap_or(false) {
let mut segments = ctx.route.split("/").skip(1).collect::<Vec<_>>();
last = segments.pop();
let mut end = 0;
for s in segments {
end += s.len();
backlinks.push((&ctx.route[0..=end], s));
end += 1; // trailing slash
}
}
last.map(|last| {
html! {
div {
@for (url, text) in backlinks {
a href=(url) style="padding-left:5pt;padding-right:5pt;" { (text) }
"/"
}
span style="color:var(--metaColor);padding-left:5pt;padding-right:5pt;" { (last) }
}
}
})
}
//
// MARK: extensions
//
//
// MARK: smart link
//
#[derive(Debug)]
pub struct SmartLink {
pub url: String,
pub title: Option<String>,
}
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(<args>)`
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::<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))
}
}

View File

@@ -0,0 +1,45 @@
use std::str::FromStr;
use markdown_it::parser::inline::{InlineRule, InlineState};
use markdown_it::{Node, NodeValue, Renderer};
use maud::Render;
use crate::components::fa::FAIcon;
#[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))
}
}

View File

@@ -0,0 +1,108 @@
use markdown_it::parser::block::{BlockRule, BlockState};
use markdown_it::parser::core::Root;
use markdown_it::{Node, NodeValue, Renderer};
//
// MARK: yaml
//
#[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
//
#[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))
}
}

View File

@@ -0,0 +1,30 @@
use markdown_it::{Node, NodeValue, Renderer};
#[derive(Debug)]
pub struct SmartLink {
pub url: String,
pub title: Option<String>,
}
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");
}
}

View File

@@ -0,0 +1,195 @@
use markdown_it::parser::inline::{InlineRule, InlineState};
use markdown_it::{Node, NodeValue, Renderer};
use maud::Render;
use crate::components::mangle::{MangledBetaEmail, MangledGoogleEmail};
#[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(<args>)`
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;
}

View File

@@ -0,0 +1,169 @@
use lazy_static::lazy_static;
use markdown_it::generics::inline::full_link;
use markdown_it::{MarkdownIt, Node};
use maud::{Markup, PreEscaped, Render, html};
use page::{Page, PageMetadata, RequestContext};
use crate::components::md::emote::InlineEmote;
use crate::components::md::frontmatter::{TomlFrontMatter, YamlFrontMatter};
use crate::components::md::link::SmartLink;
use crate::components::md::mdx::InlineMdx;
mod emote;
mod frontmatter;
mod link;
mod mdx;
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::<false>(&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_footnote::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::<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, ctx| {
let html = html.clone();
Box::pin(async move {
html! {
@if let Some(backlinks) = backlinks(page, ctx) {
(backlinks)
}
(html)
}
})
}),
..Default::default()
}
}
pub fn backlinks(page: &Page, ctx: &RequestContext) -> Option<Markup> {
let mut last = None;
let mut backlinks = vec![("/", "home")];
if page.meta.backlinks.unwrap_or(false) {
let mut segments = ctx.route.split("/").skip(1).collect::<Vec<_>>();
last = segments.pop();
let mut end = 0;
for s in segments {
end += s.len();
backlinks.push((&ctx.route[0..=end], s));
end += 1; // trailing slash
}
}
last.map(|last| {
html! {
div {
@for (url, text) in backlinks {
a href=(url) style="padding-left:5pt;padding-right:5pt;" { (text) }
"/"
}
span style="color:var(--metaColor);padding-left:5pt;padding-right:5pt;" { (last) }
}
}
})
}

View File

@@ -165,7 +165,7 @@ The document itself should also be numbered. In most cases, a `\today` on the fr
This helps synchronize the handout you _think_ the class has with the handout that the class _really_ has.
Future instructors {{color(--grey, "(and future you")}} will be thankful.
Future instructors {{color(--grey, "(and future you)")}} will be thankful.
### Items