add cache, prepare for backlinks, improve 404s
This commit is contained in:
		
							parent
							
								
									db941f02e0
								
							
						
					
					
						commit
						38e560aa51
					
				
					 4 changed files with 197 additions and 79 deletions
				
			
		
							
								
								
									
										256
									
								
								src/main.rs
									
										
									
									
									
								
							
							
						
						
									
										256
									
								
								src/main.rs
									
										
									
									
									
								
							|  | @ -1,19 +1,22 @@ | |||
| use actix_files::NamedFile; | ||||
| use actix_web::error::ErrorInternalServerError; | ||||
| use actix_web::{error, get, http, middleware, web, App, Error, HttpResponse, HttpServer}; | ||||
| use anyhow::anyhow; | ||||
| use chrono::{DateTime, Local}; | ||||
| use clap::{App as ClapApp, Arg}; | ||||
| use linkify::LinkFinder; | ||||
| use log::{info, trace}; | ||||
| use percent_encoding::utf8_percent_encode; | ||||
| use pulldown_cmark::{html, Options, Parser}; | ||||
| use regex::{Captures, Regex}; | ||||
| use std::collections::HashMap; | ||||
| use std::fs::File; | ||||
| use std::io::Read; | ||||
| use std::net::SocketAddr; | ||||
| use std::path::{Path, PathBuf}; | ||||
| use std::sync::Mutex; | ||||
| use std::time::SystemTime; | ||||
| use std::{env, fs}; | ||||
| 
 | ||||
| use actix_files::NamedFile; | ||||
| use actix_web::error::ErrorInternalServerError; | ||||
| use actix_web::{error, get, http, middleware, web, App, Error, HttpResponse, HttpServer}; | ||||
| use chrono::{DateTime, Local}; | ||||
| use clap::{App as ClapApp, Arg}; | ||||
| use linkify::LinkFinder; | ||||
| use log::info; | ||||
| use percent_encoding::utf8_percent_encode; | ||||
| use pulldown_cmark::{html, Options, Parser}; | ||||
| use regex::{Captures, Regex}; | ||||
| use tera::{Context, Tera}; | ||||
| 
 | ||||
| #[derive(Clone)] | ||||
|  | @ -24,6 +27,33 @@ struct State { | |||
|     tera: Tera, | ||||
| } | ||||
| 
 | ||||
| struct MutableState { | ||||
|     garden_cache: Mutex<GardenCache>, | ||||
| } | ||||
| 
 | ||||
| #[derive(Clone, Debug)] | ||||
| struct GardenCache { | ||||
|     pages: HashMap<PathBuf, ParsedPage>, | ||||
|     files: Vec<PathBuf>, | ||||
| } | ||||
| 
 | ||||
| impl Default for GardenCache { | ||||
|     fn default() -> Self { | ||||
|         GardenCache { | ||||
|             pages: HashMap::new(), | ||||
|             files: vec![], | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| #[derive(Clone, Debug)] | ||||
| struct ParsedPage { | ||||
|     timestamp: Option<SystemTime>, | ||||
|     title: String, | ||||
|     html: String, | ||||
|     links: Vec<String>, | ||||
| } | ||||
| 
 | ||||
| const VERSION: &str = env!("CARGO_PKG_VERSION"); | ||||
| 
 | ||||
| fn main() -> anyhow::Result<()> { | ||||
|  | @ -77,6 +107,10 @@ fn main() -> anyhow::Result<()> { | |||
|         .expect("Incorrect bind format."); | ||||
|     info!("Starting server at: {}", &bind); | ||||
| 
 | ||||
|     let mutable_state = web::Data::new(MutableState { | ||||
|         garden_cache: Mutex::new(update_garden(directory, GardenCache::default())?), | ||||
|     }); | ||||
| 
 | ||||
|     let state = State { | ||||
|         garden_dir: directory.to_path_buf(), | ||||
|         index_file: matches.value_of("INDEX_FILE").map(|s| s.to_string()), | ||||
|  | @ -89,6 +123,7 @@ fn main() -> anyhow::Result<()> { | |||
|         App::new() | ||||
|             .wrap(middleware::Logger::default()) | ||||
|             .data(state.clone()) | ||||
|             .app_data(mutable_state.clone()) | ||||
|             .service(actix_files::Files::new("/static", "templates")) | ||||
|             .service(render) | ||||
|     }) | ||||
|  | @ -101,15 +136,117 @@ fn main() -> anyhow::Result<()> { | |||
| #[get("{path:.*}")] | ||||
| async fn render( | ||||
|     request: web::HttpRequest, | ||||
|     state: web::Data<State>, | ||||
|     data: web::Data<State>, | ||||
|     state: web::Data<MutableState>, | ||||
|     path: web::Path<String>, | ||||
| ) -> Result<HttpResponse, Error> { | ||||
|     let mut files: Vec<PathBuf> = fs::read_dir(&state.garden_dir)? | ||||
|     let mut cache = state.garden_cache.lock().unwrap(); | ||||
|     *cache = update_garden(&data.garden_dir, (*cache).clone()) | ||||
|         .map_err(error::ErrorInternalServerError)?; | ||||
| 
 | ||||
|     // Redirect to index if path is empty.
 | ||||
|     if path.is_empty() { | ||||
|         let location = match data.index_file.as_ref() { | ||||
|             Some(index_file) => index_file.clone(), | ||||
|             None => cache | ||||
|                 .files | ||||
|                 .iter() | ||||
|                 .filter(|f| f.to_str().unwrap().ends_with(".md")) | ||||
|                 .collect::<Vec<&PathBuf>>() | ||||
|                 .first() | ||||
|                 .unwrap_or(&cache.files.first().unwrap()) | ||||
|                 .display() | ||||
|                 .to_string(), | ||||
|         }; | ||||
| 
 | ||||
|         return Ok(HttpResponse::Found() | ||||
|             .header(http::header::LOCATION, location.as_str()) | ||||
|             .finish()); | ||||
|     } | ||||
| 
 | ||||
|     let full_path = data.garden_dir.join(path.as_str()); | ||||
| 
 | ||||
|     // Redirect to ".md" version if requested path matches a .md file without the extension
 | ||||
|     if !full_path.exists() && full_path.extension().is_none() { | ||||
|         let md_path = format!("{}.md", path.to_string()); | ||||
|         if Path::new(&md_path).exists() { | ||||
|             return Ok(HttpResponse::Found() | ||||
|                 .header(http::header::LOCATION, md_path) | ||||
|                 .finish()); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     if full_path.exists() && !path.ends_with(".md") { | ||||
|         return Ok(NamedFile::open(full_path)?.into_response(&request)?); | ||||
|     } | ||||
| 
 | ||||
|     let page = cache.pages.get(&full_path); | ||||
| 
 | ||||
|     let mut context = Context::new(); | ||||
|     context.insert("version", VERSION); | ||||
|     context.insert( | ||||
|         "garden_title", | ||||
|         data.title.as_ref().unwrap_or(&"Digital Garden".to_string()), | ||||
|     ); | ||||
|     context.insert("files", &cache.files); | ||||
|     context.insert( | ||||
|         "page_title", | ||||
|         &match page { | ||||
|             Some(page) => page.title.clone(), | ||||
|             None => full_path | ||||
|                 .components() | ||||
|                 .last() | ||||
|                 .unwrap() | ||||
|                 .as_os_str() | ||||
|                 .to_str() | ||||
|                 .unwrap() | ||||
|                 .to_string(), | ||||
|         }, | ||||
|     ); | ||||
|     context.insert( | ||||
|         "content", | ||||
|         &match page { | ||||
|             Some(page) => page.html.clone(), | ||||
|             None => data | ||||
|                 .tera | ||||
|                 .render("_not_found.html", &Context::new()) | ||||
|                 .map_err(ErrorInternalServerError)?, | ||||
|         }, | ||||
|     ); | ||||
|     context.insert( | ||||
|         "mtime", | ||||
|         &match page { | ||||
|             Some(page) => { | ||||
|                 if let Some(timestamp) = page.timestamp { | ||||
|                     let mtime: DateTime<Local> = timestamp.into(); | ||||
|                     Some(mtime.format("%c").to_string()) | ||||
|                 } else { | ||||
|                     None | ||||
|                 } | ||||
|             } | ||||
|             None => None, | ||||
|         }, | ||||
|     ); | ||||
| 
 | ||||
|     Ok(HttpResponse::Ok().body( | ||||
|         data.tera | ||||
|             .render("main.html", &context) | ||||
|             .map_err(ErrorInternalServerError)?, | ||||
|     )) | ||||
| } | ||||
| 
 | ||||
| fn update_garden<P: AsRef<Path>>( | ||||
|     garden_path: P, | ||||
|     current: GardenCache, | ||||
| ) -> anyhow::Result<GardenCache> { | ||||
|     let garden_path = garden_path.as_ref().clone(); | ||||
| 
 | ||||
|     let mut files: Vec<PathBuf> = fs::read_dir(&garden_path)? | ||||
|         .filter_map(|entry| { | ||||
|             if let Ok(entry) = entry { | ||||
|                 let path = entry.path(); | ||||
|                 if path.is_file() { | ||||
|                     let stripped_path = path.strip_prefix(&state.garden_dir).unwrap().to_path_buf(); | ||||
|                     let stripped_path = path.strip_prefix(&garden_path).unwrap().to_path_buf(); | ||||
|                     if !stripped_path.to_str().unwrap().starts_with(".") { | ||||
|                         return Some(stripped_path); | ||||
|                     } | ||||
|  | @ -121,54 +258,42 @@ async fn render( | |||
|     files.sort(); | ||||
| 
 | ||||
|     if files.is_empty() { | ||||
|         return Err(error::ErrorNotFound("Garden is empty.")); | ||||
|         return Err(anyhow!("Garden is empty.")); | ||||
|     } | ||||
| 
 | ||||
|     if path.is_empty() { | ||||
|         let location = match state.index_file.as_ref() { | ||||
|             Some(index_file) => index_file.clone(), | ||||
|             None => files | ||||
|     let mut pages = current.pages.clone(); | ||||
| 
 | ||||
|     let markdown_paths = files | ||||
|         .iter() | ||||
|                 .filter(|f| f.to_str().unwrap().ends_with(".md")) | ||||
|                 .collect::<Vec<&PathBuf>>() | ||||
|                 .first() | ||||
|                 .unwrap_or(&files.first().unwrap()) | ||||
|                 .display() | ||||
|                 .to_string(), | ||||
|         }; | ||||
| 
 | ||||
|         return Ok(HttpResponse::Found() | ||||
|             .header(http::header::LOCATION, location.as_str()) | ||||
|             .finish()); | ||||
|         .filter(|p| p.to_str().unwrap_or("").ends_with(".md")) | ||||
|         .map(|p| garden_path.join(p)); | ||||
|     for path in markdown_paths { | ||||
|         trace!("Loading {} into cache...", path.display()); | ||||
|         let mtime = path.metadata().unwrap().modified().ok(); | ||||
|         if let Some(page) = pages.get(&path) { | ||||
|             match (mtime, page.timestamp) { | ||||
|                 (Some(fs_time), Some(last_time)) => { | ||||
|                     if fs_time == last_time { | ||||
|                         continue; | ||||
|                     } | ||||
| 
 | ||||
|     let full_path = state.garden_dir.join(path.as_str()); | ||||
|     match (full_path.exists(), full_path.extension()) { | ||||
|         (false, None) => { | ||||
|             return Ok(HttpResponse::Found() | ||||
|                 .header(http::header::LOCATION, format!("{}.md", path.to_string())) | ||||
|                 .finish()); | ||||
|                 } | ||||
|         (false, Some(_)) => return Err(error::ErrorNotFound("File not found.")), | ||||
|                 _ => {} | ||||
|             } | ||||
|         } | ||||
| 
 | ||||
|     if !path.ends_with(".md") { | ||||
|         Ok(NamedFile::open(full_path)?.into_response(&request)?) | ||||
|     } else { | ||||
|         let mut file = File::open(full_path.clone())?; | ||||
|         let mut file = File::open(&path)?; | ||||
|         let mut file_string = String::new(); | ||||
|         file.read_to_string(&mut file_string)?; | ||||
|         let markdown_source = preprocess(file_string); | ||||
|         let markdown_source = preprocess_markdown(file_string); | ||||
|         let parser = Parser::new_ext(markdown_source.as_str(), Options::all()); | ||||
|         let mut html_output = String::new(); | ||||
|         html::push_html(&mut html_output, parser); | ||||
| 
 | ||||
|         // TODO!
 | ||||
|         let h1_regex = Regex::new(r"<h1>([^>]+)</h1>").unwrap(); | ||||
|         let page_title = match h1_regex.captures(&html_output) { | ||||
|         let title = match h1_regex.captures(&html_output) { | ||||
|             Some(h1_match) => h1_match.get(1).unwrap().as_str(), | ||||
|             _ => full_path | ||||
|             _ => &path | ||||
|                 .components() | ||||
|                 .last() | ||||
|                 .unwrap() | ||||
|  | @ -177,38 +302,23 @@ async fn render( | |||
|                 .unwrap_or("???"), | ||||
|         }; | ||||
| 
 | ||||
|         let mtime: Option<DateTime<Local>> = match file.metadata() { | ||||
|             Ok(metadata) => metadata.modified().ok().map(|mtime| mtime.into()), | ||||
|             _ => None, | ||||
|         }; | ||||
| 
 | ||||
|         let mut context = Context::new(); | ||||
|         context.insert( | ||||
|             "garden_title", | ||||
|             state | ||||
|                 .title | ||||
|                 .as_ref() | ||||
|                 .unwrap_or(&"Digital Garden".to_string()), | ||||
|         pages.insert( | ||||
|             path.clone(), | ||||
|             ParsedPage { | ||||
|                 timestamp: mtime, | ||||
|                 html: html_output.clone(), | ||||
|                 title: String::from(title), | ||||
|                 links: vec![], // todo!,
 | ||||
|             }, | ||||
|         ); | ||||
|         context.insert("page_title", page_title); | ||||
|         context.insert("files", &files); | ||||
|         context.insert("content", &html_output); | ||||
|         context.insert( | ||||
|             "mtime", | ||||
|             &mtime.map_or("???".to_string(), |t| t.format("%c").to_string()), | ||||
|         ); | ||||
|         context.insert("version", VERSION); | ||||
| 
 | ||||
|         Ok(HttpResponse::Ok().body( | ||||
|             state | ||||
|                 .tera | ||||
|                 .render("main.html", &context) | ||||
|                 .map_err(ErrorInternalServerError)?, | ||||
|         )) | ||||
|     } | ||||
| 
 | ||||
|     let result = GardenCache { files, pages }; | ||||
|     trace!("{:#?}", result); | ||||
|     Ok(result) | ||||
| } | ||||
| 
 | ||||
| fn preprocess(string: String) -> String { | ||||
| fn preprocess_markdown(string: String) -> String { | ||||
|     let double_brackets = Regex::new(r"\[\[(?P<inner>[\w .]+)\]\]").unwrap(); | ||||
|     let finder = LinkFinder::new(); | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										1
									
								
								templates/_not_found.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								templates/_not_found.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1 @@ | |||
| <div class="message">File does not (yet?) exist!</div> | ||||
|  | @ -111,3 +111,8 @@ pre { | |||
|     overflow-x: auto; | ||||
|     word-wrap: normal; | ||||
| } | ||||
| 
 | ||||
| .message { | ||||
|     text-align: center; | ||||
|     padding: 2em; | ||||
| } | ||||
|  | @ -28,9 +28,11 @@ | |||
|     <main> | ||||
|         {{content | safe}} | ||||
| 
 | ||||
|         {% if mtime %} | ||||
|             <footer> | ||||
|                 Last modified at {{mtime}} | ||||
|             </footer> | ||||
|         {% endif %} | ||||
|     </main> | ||||
| </body> | ||||
| 
 | ||||
|  |  | |||
		Loading…
	
	Add table
		
		Reference in a new issue