A little app to serve my photography from my personal website

extract table of contents and render it

ericwood.org c7cba5fb 3d749f71

Waiting for spindle ...
+129 -43
+101 -41
src/blog.rs
··· 1 1 use std::{ 2 - collections::HashMap, 2 + borrow::Borrow, 3 + collections::{HashMap, VecDeque}, 3 4 fmt::{self, Write}, 4 5 fs::{self, File, read_to_string}, 5 6 io::{BufRead, BufReader, Write as IoWrite}, ··· 8 9 9 10 use arborium::Highlighter; 10 11 use comrak::{ 12 + Arena, 11 13 adapters::{HeadingAdapter, HeadingMeta, SyntaxHighlighterAdapter}, 12 - markdown_to_html_with_plugins, 13 - nodes::Sourcepos, 14 - options, 14 + arena_tree::NodeEdge, 15 + html::format_document_with_plugins, 16 + nodes::{AstNode, NodeValue, Sourcepos}, 17 + options, parse_document, 15 18 }; 16 - use minijinja::context; 19 + use serde::Serialize; 17 20 18 - use crate::{AppState, config::Config, date_time::DateTime, templates::render}; 21 + use crate::{ 22 + AppState, 23 + config::Config, 24 + date_time::DateTime, 25 + views::{View, blog::BlogShow}, 26 + }; 19 27 20 28 #[derive(serde::Deserialize, serde::Serialize, Clone)] 21 29 pub struct BlogPost { ··· 177 185 } 178 186 } 179 187 180 - pub fn render_post(path: &PathBuf) -> anyhow::Result<String> { 188 + fn text_from_node<'a>(node: &'a AstNode<'a>) -> String { 189 + let mut text = String::new(); 190 + for child in node.children() { 191 + if let NodeValue::Text(s) = &child.data.borrow().value { 192 + text.push_str(s.borrow()); 193 + } else { 194 + text.push_str(&text_from_node(child)); 195 + } 196 + } 197 + 198 + text 199 + } 200 + 201 + #[derive(Serialize, Clone, Debug)] 202 + pub struct Section { 203 + pub name: String, 204 + pub slug: String, 205 + pub level: u8, 206 + pub subsections: Vec<Section>, 207 + } 208 + 209 + fn create_toc<'a>(root: &'a AstNode<'a>) -> Vec<Section> { 210 + let mut sections: Vec<Section> = Vec::new(); 211 + 212 + for edge in root.traverse() { 213 + if let NodeEdge::Start(node) = edge 214 + && let NodeValue::Heading(ref heading) = node.data().value 215 + { 216 + let name = text_from_node(node); 217 + let slug = slug::slugify(&name); 218 + let level = heading.level; 219 + 220 + let section = Section { 221 + name, 222 + slug, 223 + level, 224 + subsections: Vec::new(), 225 + }; 226 + sections.push(section); 227 + } 228 + } 229 + 230 + let mut toc: VecDeque<Section> = VecDeque::new(); 231 + let mut queue: VecDeque<Section> = VecDeque::from(sections); 232 + while let Some(mut current) = queue.pop_back() { 233 + while let Some(section) = toc.front() 234 + && section.level > current.level 235 + { 236 + let section = toc.pop_front().unwrap(); 237 + current.subsections.push(section); 238 + } 239 + 240 + toc.push_front(current); 241 + } 242 + 243 + toc.into() 244 + } 245 + 246 + pub fn render_post(path: &PathBuf) -> anyhow::Result<(String, Vec<Section>)> { 181 247 let md = read_to_string(path)?; 182 248 let syntax_adapter = SyntaxAdapter::new(); 183 249 let heading_adapter = LinkedHeadingAdapter; 184 250 let mut plugins = options::Plugins::default(); 185 251 plugins.render.codefence_syntax_highlighter = Some(&syntax_adapter); 186 252 plugins.render.heading_adapter = Some(&heading_adapter); 187 - let body = markdown_to_html_with_plugins( 188 - &md, 189 - &comrak::Options { 190 - extension: options::Extension::builder() 191 - .math_dollars(true) 192 - .multiline_block_quotes(true) 193 - .strikethrough(true) 194 - .superscript(true) 195 - .footnotes(true) 196 - .underline(true) 197 - .greentext(true) 198 - .autolink(true) 199 - .alerts(true) 200 - .table(true) 201 - .math_code(true) 202 - .maybe_front_matter_delimiter(Some("---".to_string())) 203 - .build(), 204 - parse: options::Parse::builder().build(), 205 - render: options::Render::builder().build(), 206 - }, 207 - &plugins, 208 - ); 253 + let options = comrak::Options { 254 + extension: options::Extension::builder() 255 + .math_dollars(true) 256 + .multiline_block_quotes(true) 257 + .strikethrough(true) 258 + .superscript(true) 259 + .footnotes(true) 260 + .underline(true) 261 + .greentext(true) 262 + .autolink(true) 263 + .alerts(true) 264 + .table(true) 265 + .math_code(true) 266 + .maybe_front_matter_delimiter(Some("---".to_string())) 267 + .build(), 268 + parse: options::Parse::builder().build(), 269 + render: options::Render::builder().build(), 270 + }; 271 + let arena = Arena::new(); 272 + let root = parse_document(&arena, &md, &options); 273 + let toc = create_toc(root); 274 + 275 + let mut body = String::new(); 276 + format_document_with_plugins(root, &options, &mut body, &plugins)?; 209 277 210 - Ok(body) 278 + Ok((body, toc)) 211 279 } 212 280 213 281 // Render all of our blog posts as fully static HTML files so we can serve them up without having ··· 219 287 } 220 288 221 289 for post in state.blog_slugs.values() { 222 - let body = render_post(&post.file_path)?; 223 - let post = post.clone(); 224 - let rendered = render( 225 - &state.reloader, 226 - "blog/show", 227 - context! { 228 - post, 229 - body, 230 - }, 231 - )?; 290 + let view = BlogShow::new(post); 291 + let rendered = view.render(&state.reloader)?; 232 292 233 - let mut file = File::create(post.cache_path)?; 293 + let mut file = File::create(post.cache_path.clone())?; 234 294 file.write_all(rendered.as_bytes())?; 235 295 } 236 296
+27 -2
src/views/blog/show/mod.rs
··· 2 2 use minijinja_autoreload::AutoReloader; 3 3 4 4 use crate::{ 5 - blog::{BlogPost, render_post}, 5 + blog::{BlogPost, Section, render_post}, 6 6 templates::render, 7 7 views::View, 8 8 }; ··· 19 19 20 20 impl<'a> View for BlogShow<'a> { 21 21 fn render(&self, reloader: &AutoReloader) -> anyhow::Result<String> { 22 - let body = render_post(&self.post.file_path)?; 22 + let (body, toc) = render_post(&self.post.file_path)?; 23 23 let has_code = body.contains("<pre class=\"highlighted\">"); 24 + let toc_html = render_toc(toc); 24 25 let html = render( 25 26 reloader, 26 27 "views/blog/show", 27 28 context! { 28 29 post => self.post, 29 30 body, 31 + toc_html, 30 32 has_code, 31 33 }, 32 34 )?; ··· 34 36 Ok(html) 35 37 } 36 38 } 39 + 40 + fn render_toc(toc: Vec<Section>) -> String { 41 + if toc.is_empty() { 42 + return "".to_string(); 43 + } 44 + 45 + let mut markup = vec!["<ul>".to_string()]; 46 + for section in toc { 47 + let Section { 48 + name, 49 + slug, 50 + subsections, 51 + .. 52 + } = section; 53 + let subsections = render_toc(subsections); 54 + markup.push(format!( 55 + "<li><a href=\"#{slug}\">{name}</a>\n{subsections}</li>" 56 + )); 57 + } 58 + 59 + markup.push("</ul>".to_string()); 60 + markup.join("\n") 61 + }
+1
src/views/blog/show/template.jinja
··· 13 13 <p> 14 14 <time datetime="{{ post.published_at }}">{{ post.published_at }}</time> 15 15 </p> 16 + {{ toc_html }} 16 17 <div class="blog__body"> 17 18 {{ body }} 18 19 </div>