From a3ff195de99741a3d8a84bac979ad66587c92ee6 Mon Sep 17 00:00:00 2001 From: rm-dr <96270320+rm-dr@users.noreply.github.com> Date: Thu, 6 Nov 2025 22:20:01 -0800 Subject: [PATCH] Footnotes --- crates/lib/md-footnote/src/back_refs.rs | 4 +- crates/lib/md-footnote/tests/fixtures.rs | 4 + crates/service/service-webpage/Cargo.toml | 4 +- crates/service/service-webpage/css/text.scss | 35 +- .../service-webpage/src/components/md.rs | 541 ------------------ .../src/components/md/emote.rs | 45 ++ .../src/components/md/frontmatter.rs | 108 ++++ .../service-webpage/src/components/md/link.rs | 30 + .../service-webpage/src/components/md/mdx.rs | 195 +++++++ .../service-webpage/src/components/md/mod.rs | 169 ++++++ .../src/pages/htwah-typesetting.md | 2 +- 11 files changed, 579 insertions(+), 558 deletions(-) delete mode 100644 crates/service/service-webpage/src/components/md.rs create mode 100644 crates/service/service-webpage/src/components/md/emote.rs create mode 100644 crates/service/service-webpage/src/components/md/frontmatter.rs create mode 100644 crates/service/service-webpage/src/components/md/link.rs create mode 100644 crates/service/service-webpage/src/components/md/mdx.rs create mode 100644 crates/service/service-webpage/src/components/md/mod.rs diff --git a/crates/lib/md-footnote/src/back_refs.rs b/crates/lib/md-footnote/src/back_refs.rs index 060ff02..342619c 100644 --- a/crates/lib/md-footnote/src/back_refs.rs +++ b/crates/lib/md-footnote/src/back_refs.rs @@ -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"); } } diff --git a/crates/lib/md-footnote/tests/fixtures.rs b/crates/lib/md-footnote/tests/fixtures.rs index 88c26b1..06eab19 100644 --- a/crates/lib/md-footnote/tests/fixtures.rs +++ b/crates/lib/md-footnote/tests/fixtures.rs @@ -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); } +*/ diff --git a/crates/service/service-webpage/Cargo.toml b/crates/service/service-webpage/Cargo.toml index 2a188d6..7e2cfdb 100644 --- a/crates/service/service-webpage/Cargo.toml +++ b/crates/service/service-webpage/Cargo.toml @@ -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 } diff --git a/crates/service/service-webpage/css/text.scss b/crates/service/service-webpage/css/text.scss index df6a564..827f1ae 100644 --- a/crates/service/service-webpage/css/text.scss +++ b/crates/service/service-webpage/css/text.scss @@ -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; diff --git a/crates/service/service-webpage/src/components/md.rs b/crates/service/service-webpage/src/components/md.rs deleted file mode 100644 index 3adb068..0000000 --- a/crates/service/service-webpage/src/components/md.rs +++ /dev/null @@ -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::(&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::().before_all(); - md.block.add_rule::().before_all(); - - md.inline.add_rule::(); - md.inline.add_rule::(); - md.inline.add_rule::(); - - 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, toml::de::Error> { - root_node - .children - .first() - .and_then(|x| x.cast::()) - .map(|x| toml::from_str::(&x.content)) - .map_or(Ok(None), |v| v.map(Some)) -} - -pub fn page_from_markdown(md: impl Into, default_image: Option) -> 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 { - 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::>(); - 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, -} - -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()` - 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::() { - 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::(); - 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::() { - return None; - } - - if state.line != 0 { - return None; - } - - let opening = state - .get_line(state.line) - .chars() - .take_while(|c| *c == '+') - .collect::(); - 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)) - } -} diff --git a/crates/service/service-webpage/src/components/md/emote.rs b/crates/service/service-webpage/src/components/md/emote.rs new file mode 100644 index 0000000..6850fd8 --- /dev/null +++ b/crates/service/service-webpage/src/components/md/emote.rs @@ -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)) + } +} diff --git a/crates/service/service-webpage/src/components/md/frontmatter.rs b/crates/service/service-webpage/src/components/md/frontmatter.rs new file mode 100644 index 0000000..e0e74ca --- /dev/null +++ b/crates/service/service-webpage/src/components/md/frontmatter.rs @@ -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::() { + 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::(); + 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::() { + return None; + } + + if state.line != 0 { + return None; + } + + let opening = state + .get_line(state.line) + .chars() + .take_while(|c| *c == '+') + .collect::(); + 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)) + } +} diff --git a/crates/service/service-webpage/src/components/md/link.rs b/crates/service/service-webpage/src/components/md/link.rs new file mode 100644 index 0000000..e7a936b --- /dev/null +++ b/crates/service/service-webpage/src/components/md/link.rs @@ -0,0 +1,30 @@ +use markdown_it::{Node, NodeValue, Renderer}; + +#[derive(Debug)] +pub struct SmartLink { + pub url: String, + pub title: Option, +} + +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"); + } +} diff --git a/crates/service/service-webpage/src/components/md/mdx.rs b/crates/service/service-webpage/src/components/md/mdx.rs new file mode 100644 index 0000000..c879dfe --- /dev/null +++ b/crates/service/service-webpage/src/components/md/mdx.rs @@ -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()` + 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; +} diff --git a/crates/service/service-webpage/src/components/md/mod.rs b/crates/service/service-webpage/src/components/md/mod.rs new file mode 100644 index 0000000..e37f6b3 --- /dev/null +++ b/crates/service/service-webpage/src/components/md/mod.rs @@ -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::(&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::().before_all(); + md.block.add_rule::().before_all(); + + md.inline.add_rule::(); + md.inline.add_rule::(); + + 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, toml::de::Error> { + root_node + .children + .first() + .and_then(|x| x.cast::()) + .map(|x| toml::from_str::(&x.content)) + .map_or(Ok(None), |v| v.map(Some)) +} + +pub fn page_from_markdown(md: impl Into, default_image: Option) -> 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 { + 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::>(); + 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) } + } + } + }) +} diff --git a/crates/service/service-webpage/src/pages/htwah-typesetting.md b/crates/service/service-webpage/src/pages/htwah-typesetting.md index eb75c5c..210d9f3 100644 --- a/crates/service/service-webpage/src/pages/htwah-typesetting.md +++ b/crates/service/service-webpage/src/pages/htwah-typesetting.md @@ -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