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
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:
@@ -40,7 +40,7 @@ pub struct FootnoteRefAnchor {
|
|||||||
impl NodeValue for FootnoteRefAnchor {
|
impl NodeValue for FootnoteRefAnchor {
|
||||||
fn render(&self, _: &Node, fmt: &mut dyn markdown_it::Renderer) {
|
fn render(&self, _: &Node, fmt: &mut dyn markdown_it::Renderer) {
|
||||||
for ref_id in self.ref_ids.iter() {
|
for ref_id in self.ref_ids.iter() {
|
||||||
fmt.text(" ");
|
fmt.text_raw(" ");
|
||||||
fmt.open(
|
fmt.open(
|
||||||
"a",
|
"a",
|
||||||
&[
|
&[
|
||||||
@@ -49,7 +49,7 @@ impl NodeValue for FootnoteRefAnchor {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
// # ↩ with escape code to prevent display as Apple Emoji on iOS
|
// # ↩ 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");
|
fmt.close("a");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
#![expect(unused_imports)]
|
||||||
|
#![expect(unused_crate_dependencies)]
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use testing::fixture;
|
use testing::fixture;
|
||||||
|
|
||||||
|
/*
|
||||||
#[fixture("tests/fixtures/[!_]*.md")]
|
#[fixture("tests/fixtures/[!_]*.md")]
|
||||||
fn test_html(file: PathBuf) {
|
fn test_html(file: PathBuf) {
|
||||||
let f = md_dev::read_fixture_file(file);
|
let f = md_dev::read_fixture_file(file);
|
||||||
@@ -13,3 +16,4 @@ fn test_html(file: PathBuf) {
|
|||||||
|
|
||||||
md_dev::assert_no_diff(f, &actual);
|
md_dev::assert_no_diff(f, &actual);
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ assetserver = { workspace = true }
|
|||||||
toolbox = { workspace = true }
|
toolbox = { workspace = true }
|
||||||
page = { workspace = true }
|
page = { workspace = true }
|
||||||
|
|
||||||
|
md-footnote = { workspace = true }
|
||||||
|
|
||||||
|
markdown-it = { workspace = true }
|
||||||
axum = { workspace = true }
|
axum = { workspace = true }
|
||||||
tracing = { workspace = true }
|
tracing = { workspace = true }
|
||||||
maud = { workspace = true }
|
maud = { workspace = true }
|
||||||
markdown-it = { workspace = true }
|
|
||||||
emojis = { workspace = true }
|
emojis = { workspace = true }
|
||||||
strum = { workspace = true }
|
strum = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
|||||||
@@ -43,33 +43,42 @@ a:hover {
|
|||||||
transition: 150ms;
|
transition: 150ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
clear: both;
|
clear: both;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
text-align: left
|
text-align: left
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footnote-item p {
|
||||||
|
|
||||||
.footnote-definition {
|
|
||||||
margin: 0 0 0 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footnote-definition-label {
|
|
||||||
color: var(--metaColor);
|
|
||||||
}
|
|
||||||
|
|
||||||
.footnote-definition p {
|
|
||||||
display: inline;
|
display: inline;
|
||||||
padding: 0 0 0 1rem;
|
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 {
|
.footContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
45
crates/service/service-webpage/src/components/md/emote.rs
Normal file
45
crates/service/service-webpage/src/components/md/emote.rs
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
108
crates/service/service-webpage/src/components/md/frontmatter.rs
Normal file
108
crates/service/service-webpage/src/components/md/frontmatter.rs
Normal 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
30
crates/service/service-webpage/src/components/md/link.rs
Normal file
30
crates/service/service-webpage/src/components/md/link.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
195
crates/service/service-webpage/src/components/md/mdx.rs
Normal file
195
crates/service/service-webpage/src/components/md/mdx.rs
Normal 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;
|
||||||
|
}
|
||||||
169
crates/service/service-webpage/src/components/md/mod.rs
Normal file
169
crates/service/service-webpage/src/components/md/mod.rs
Normal 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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.
|
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
|
### Items
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user