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::fs::File;
|
||||||
use std::io::Read;
|
use std::io::Read;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::SystemTime;
|
||||||
use std::{env, fs};
|
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};
|
use tera::{Context, Tera};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
|
@ -24,6 +27,33 @@ struct State {
|
||||||
tera: Tera,
|
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");
|
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
|
@ -77,6 +107,10 @@ fn main() -> anyhow::Result<()> {
|
||||||
.expect("Incorrect bind format.");
|
.expect("Incorrect bind format.");
|
||||||
info!("Starting server at: {}", &bind);
|
info!("Starting server at: {}", &bind);
|
||||||
|
|
||||||
|
let mutable_state = web::Data::new(MutableState {
|
||||||
|
garden_cache: Mutex::new(update_garden(directory, GardenCache::default())?),
|
||||||
|
});
|
||||||
|
|
||||||
let state = State {
|
let state = State {
|
||||||
garden_dir: directory.to_path_buf(),
|
garden_dir: directory.to_path_buf(),
|
||||||
index_file: matches.value_of("INDEX_FILE").map(|s| s.to_string()),
|
index_file: matches.value_of("INDEX_FILE").map(|s| s.to_string()),
|
||||||
|
@ -89,6 +123,7 @@ fn main() -> anyhow::Result<()> {
|
||||||
App::new()
|
App::new()
|
||||||
.wrap(middleware::Logger::default())
|
.wrap(middleware::Logger::default())
|
||||||
.data(state.clone())
|
.data(state.clone())
|
||||||
|
.app_data(mutable_state.clone())
|
||||||
.service(actix_files::Files::new("/static", "templates"))
|
.service(actix_files::Files::new("/static", "templates"))
|
||||||
.service(render)
|
.service(render)
|
||||||
})
|
})
|
||||||
|
@ -101,15 +136,117 @@ fn main() -> anyhow::Result<()> {
|
||||||
#[get("{path:.*}")]
|
#[get("{path:.*}")]
|
||||||
async fn render(
|
async fn render(
|
||||||
request: web::HttpRequest,
|
request: web::HttpRequest,
|
||||||
state: web::Data<State>,
|
data: web::Data<State>,
|
||||||
|
state: web::Data<MutableState>,
|
||||||
path: web::Path<String>,
|
path: web::Path<String>,
|
||||||
) -> Result<HttpResponse, Error> {
|
) -> 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| {
|
.filter_map(|entry| {
|
||||||
if let Ok(entry) = entry {
|
if let Ok(entry) = entry {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.is_file() {
|
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(".") {
|
if !stripped_path.to_str().unwrap().starts_with(".") {
|
||||||
return Some(stripped_path);
|
return Some(stripped_path);
|
||||||
}
|
}
|
||||||
|
@ -121,54 +258,42 @@ async fn render(
|
||||||
files.sort();
|
files.sort();
|
||||||
|
|
||||||
if files.is_empty() {
|
if files.is_empty() {
|
||||||
return Err(error::ErrorNotFound("Garden is empty."));
|
return Err(anyhow!("Garden is empty."));
|
||||||
}
|
}
|
||||||
|
|
||||||
if path.is_empty() {
|
let mut pages = current.pages.clone();
|
||||||
let location = match state.index_file.as_ref() {
|
|
||||||
Some(index_file) => index_file.clone(),
|
let markdown_paths = files
|
||||||
None => files
|
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|f| f.to_str().unwrap().ends_with(".md"))
|
.filter(|p| p.to_str().unwrap_or("").ends_with(".md"))
|
||||||
.collect::<Vec<&PathBuf>>()
|
.map(|p| garden_path.join(p));
|
||||||
.first()
|
for path in markdown_paths {
|
||||||
.unwrap_or(&files.first().unwrap())
|
trace!("Loading {} into cache...", path.display());
|
||||||
.display()
|
let mtime = path.metadata().unwrap().modified().ok();
|
||||||
.to_string(),
|
if let Some(page) = pages.get(&path) {
|
||||||
};
|
match (mtime, page.timestamp) {
|
||||||
|
(Some(fs_time), Some(last_time)) => {
|
||||||
return Ok(HttpResponse::Found()
|
if fs_time == last_time {
|
||||||
.header(http::header::LOCATION, location.as_str())
|
continue;
|
||||||
.finish());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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") {
|
let mut file = File::open(&path)?;
|
||||||
Ok(NamedFile::open(full_path)?.into_response(&request)?)
|
|
||||||
} else {
|
|
||||||
let mut file = File::open(full_path.clone())?;
|
|
||||||
let mut file_string = String::new();
|
let mut file_string = String::new();
|
||||||
file.read_to_string(&mut file_string)?;
|
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 parser = Parser::new_ext(markdown_source.as_str(), Options::all());
|
||||||
let mut html_output = String::new();
|
let mut html_output = String::new();
|
||||||
html::push_html(&mut html_output, parser);
|
html::push_html(&mut html_output, parser);
|
||||||
|
|
||||||
// TODO!
|
// TODO!
|
||||||
let h1_regex = Regex::new(r"<h1>([^>]+)</h1>").unwrap();
|
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(),
|
Some(h1_match) => h1_match.get(1).unwrap().as_str(),
|
||||||
_ => full_path
|
_ => &path
|
||||||
.components()
|
.components()
|
||||||
.last()
|
.last()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
@ -177,38 +302,23 @@ async fn render(
|
||||||
.unwrap_or("???"),
|
.unwrap_or("???"),
|
||||||
};
|
};
|
||||||
|
|
||||||
let mtime: Option<DateTime<Local>> = match file.metadata() {
|
pages.insert(
|
||||||
Ok(metadata) => metadata.modified().ok().map(|mtime| mtime.into()),
|
path.clone(),
|
||||||
_ => None,
|
ParsedPage {
|
||||||
};
|
timestamp: mtime,
|
||||||
|
html: html_output.clone(),
|
||||||
let mut context = Context::new();
|
title: String::from(title),
|
||||||
context.insert(
|
links: vec![], // todo!,
|
||||||
"garden_title",
|
},
|
||||||
state
|
|
||||||
.title
|
|
||||||
.as_ref()
|
|
||||||
.unwrap_or(&"Digital Garden".to_string()),
|
|
||||||
);
|
);
|
||||||
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 double_brackets = Regex::new(r"\[\[(?P<inner>[\w .]+)\]\]").unwrap();
|
||||||
let finder = LinkFinder::new();
|
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;
|
overflow-x: auto;
|
||||||
word-wrap: normal;
|
word-wrap: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2em;
|
||||||
|
}
|
|
@ -28,9 +28,11 @@
|
||||||
<main>
|
<main>
|
||||||
{{content | safe}}
|
{{content | safe}}
|
||||||
|
|
||||||
|
{% if mtime %}
|
||||||
<footer>
|
<footer>
|
||||||
Last modified at {{mtime}}
|
Last modified at {{mtime}}
|
||||||
</footer>
|
</footer>
|
||||||
|
{% endif %}
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue