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 {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
*/
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Future instructors {{color(--grey, "(and future you")}} will be thankful.
|
||||
Future instructors {{color(--grey, "(and future you)")}} will be thankful.
|
||||
|
||||
### Items
|
||||
|
||||
|
||||
Reference in New Issue
Block a user