diff --git a/.gitignore b/.gitignore index 0c5fade..f6f54a2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ sites/ target/ +themes/ Cargo.lock diff --git a/Cargo.toml b/Cargo.toml index 5d54025..5cd3650 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,8 @@ bitcoin_hashes = { version = "0.12", features = ["serde"] } chrono = { version = "0", features = ["serde"] } clap = { version = "4", features = ["derive"] } femme = "2" +globset = "0.4" +grass = {version = "0.13", default-features = false, features = ["random"]} http-types = "2" lazy_static = "1.4" markdown = "1.0.0-alpha.3" diff --git a/README.md b/README.md index 2d1bfe9..4929d76 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Things are definitely going to improve, but I am too busy building a solid found ## Themes -Not only there is no stable UI, but there are no usable themes included. +Servus currently supports Zola's [Hyde](https://github.com/getzola/hyde/) theme. ## Building @@ -136,30 +136,20 @@ Each of these "sites" has the following structure: │ └── posts │ ├── yyyy-mm-dd-post1.md │ └── [...] -├── _layouts -│ ├── includes -│ │ └── [...] -│ ├── base.html -│ ├── note.html -│ ├── page.html -│ └── post.html ├── favicon.ico └── [...] ``` Files and directories starting with "." are ignored. -Files and directories starting with "_" have special meaning: `_config.toml`, `_content`, `_layouts`. +Files and directories starting with "_" have special meaning: `_config.toml`, `_content`. Anything else will be directly served to the clients requesting it. ## _config.toml -Every site needs a config file which has one section named `site`. - -All properties present under `[site]` are passed directly to the templates: `title` becomes `site.title`, `url` becomes `site.url`, etc. - -`post_permalink`, if specified, is used to generate permalinks for posts by replacing `:slug` with the actual *slug* of the post. If not specified, it defaults to `/posts/:slug`. +Required: `base_url`, `theme`. +Optional: `pubkey`, `title`. `pubkey`, if specified, is used to enable posting using the Nostr protocol. Only events from the specified pubkey will be accepted, after validating the signature. @@ -173,14 +163,13 @@ The following variables are passed to the templates: * `data` - any data loaded from YAML files in `_content/data/` * `posts` - a list of all the posts -* `resource` - the current resource (post / page / note) being rendered +* `page` - the current resource (post / page / note) being rendered * `servus.version` - the version of Servus currently running -* `site` - the `[site]` section in `_config.toml` +* `config` - the values from `_config.toml` ### Resource variables -* `resource.date` - the date associated with this resource (post / note) -* `resource.url` - the URL of this resource +* `page.date` - the date associated with this resource (post / note) ## Posting diff --git a/build.rs b/build.rs index 60c5b19..81736df 100644 --- a/build.rs +++ b/build.rs @@ -5,16 +5,6 @@ fn main() { let admin_index_html = fs::read_to_string("admin/index.html").unwrap(); - let index_md = fs::read_to_string("themes/default/_content/pages/index.md").unwrap(); - let posts_md = fs::read_to_string("themes/default/_content/pages/posts.md").unwrap(); - - let base_html = fs::read_to_string("themes/default/_layouts/base.html").unwrap(); - let note_html = fs::read_to_string("themes/default/_layouts/note.html").unwrap(); - let page_html = fs::read_to_string("themes/default/_layouts/page.html").unwrap(); - let post_html = fs::read_to_string("themes/default/_layouts/post.html").unwrap(); - - let style_css = fs::read_to_string("themes/default/style.css").unwrap(); - let out_dir = std::env::var_os("OUT_DIR").unwrap(); std::fs::write( @@ -25,34 +15,4 @@ pub const INDEX_HTML: &str = r#"%%index_html%%"#; .replace("%%index_html%%", &admin_index_html), ) .unwrap(); - - std::fs::write( - std::path::Path::new(&out_dir).join("default_theme.rs"), - r##" -use std::{fs, io::Write}; -fn get_path(site_path: &str, extra: &str) -> std::path::PathBuf { - [site_path, extra].iter().collect() -} -const INDEX_MD: &str = r"%%index_md%%"; -const POSTS_MD: &str = r"%%posts_md%%"; -const BASE_HTML: &str = r#"%%base_html%%"#; -const NOTE_HTML: &str = r#"%%note_html%%"#; -const PAGE_HTML: &str = r#"%%page_html%%"#; -const POST_HTML: &str = r#"%%post_html%%"#; -const STYLE_CSS: &str = r#"%%style_css%%"#; - -pub fn generate(site_path: &str) { - fs::create_dir_all(get_path(site_path, "_content/pages")).unwrap(); - fs::create_dir_all(get_path(site_path, "_layouts")).unwrap(); - write!(fs::File::create(get_path(site_path, "_content/pages/index.md")).unwrap(), "{}", INDEX_MD).unwrap(); - write!(fs::File::create(get_path(site_path, "_content/pages/posts.md")).unwrap(), "{}", POSTS_MD).unwrap(); - write!(fs::File::create(get_path(site_path, "_layouts/base.html")).unwrap(), "{}", BASE_HTML).unwrap(); - write!(fs::File::create(get_path(site_path, "_layouts/note.html")).unwrap(), "{}", NOTE_HTML).unwrap(); - write!(fs::File::create(get_path(site_path, "_layouts/page.html")).unwrap(), "{}", PAGE_HTML).unwrap(); - write!(fs::File::create(get_path(site_path, "_layouts/post.html")).unwrap(), "{}", POST_HTML).unwrap(); - write!(fs::File::create(get_path(site_path, "style.css")).unwrap(), "{}", STYLE_CSS).unwrap(); -} -"##.replace("%%index_md%%", &index_md).replace("%%posts_md%%", &posts_md).replace("%%base_html%%", &base_html).replace("%%note_html%%", ¬e_html).replace("%%page_html%%", &page_html).replace("%%post_html%%", &post_html).replace("%%style_css%%", &style_css), - ) - .unwrap(); } diff --git a/src/main.rs b/src/main.rs index dc7dcd6..5b2a459 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,9 +23,14 @@ mod admin { mod content; mod nostr; +mod resource; +mod sass; mod site; +mod template; +mod theme; use site::Site; +use theme::Theme; #[derive(Parser)] struct Cli { @@ -50,6 +55,7 @@ struct Cli { #[derive(Clone)] struct State { + themes: Arc>>, sites: Arc>>, } @@ -85,12 +91,12 @@ fn build_raw_response(content: Vec, mime: mime::Mime) -> Response { fn render_and_build_response(site: &Site, resource_path: String) -> Response { let resources = site.resources.read().unwrap(); - let event_ref = resources.get(&resource_path).unwrap(); + let resource = resources.get(&resource_path).unwrap(); Response::builder(StatusCode::Ok) .content_type(mime::HTML) .header("Access-Control-Allow-Origin", "*") - .body(&*event_ref.render(site)) + .body(&*resource.render(site)) .build() } @@ -104,8 +110,8 @@ async fn handle_websocket( nostr::Message::Event(cmd) => { { if let Some(site) = get_site(&request) { - if let Some(site_pubkey) = site.config.get("pubkey") { - if cmd.event.pubkey != site_pubkey.as_str().unwrap() { + if let Some(site_pubkey) = site.config.pubkey { + if cmd.event.pubkey != site_pubkey { log::info!( "Ignoring event for unknown pubkey: {}.", cmd.event.pubkey @@ -291,7 +297,7 @@ async fn handle_request(request: Request) -> tide::Result { } if let Some(site) = get_site(&request) { - if let Some((mime, response)) = site::render_standard_resource(path, &site) { + if let Some((mime, response)) = resource::render_standard_resource(path, &site) { return Ok(Response::builder(StatusCode::Ok) .content_type(mime) .header("Access-Control-Allow-Origin", "*") @@ -299,18 +305,29 @@ async fn handle_request(request: Request) -> tide::Result { .build()); } - let existing_posts: Vec; + let site_resources: Vec; { - let posts = site.resources.read().unwrap(); - existing_posts = posts.keys().cloned().collect(); + let resources = site.resources.read().unwrap(); + site_resources = resources.keys().cloned().collect(); } + + let themes = request.state().themes.read().unwrap(); + let theme = themes.get(&site.config.theme.clone().unwrap()).unwrap(); + let mut resource_path = format!("/{}", &path); - if existing_posts.contains(&resource_path) { - Ok(render_and_build_response(&site, resource_path)) + if site_resources.contains(&resource_path) { + return Ok(render_and_build_response(&site, resource_path)); } else { + let theme_resources = theme.resources.read().unwrap(); + if theme_resources.contains_key(&resource_path) { + let content = theme_resources.get(&resource_path).unwrap(); + let guess = mime_guess::from_path(resource_path); + let mime = mime::Mime::from_str(guess.first().unwrap().essence_str()).unwrap(); + return Ok(build_raw_response(content.as_bytes().to_vec(), mime)); + } resource_path = format!("{}/index", &resource_path); - if existing_posts.contains(&resource_path) { - Ok(render_and_build_response(&site, resource_path)) + if site_resources.contains(&resource_path) { + return Ok(render_and_build_response(&site, resource_path)); } else { resource_path = format!("{}/{}", site.path, path); for part in resource_path.split('/').collect::>() { @@ -324,7 +341,7 @@ async fn handle_request(request: Request) -> tide::Result { let raw_content = fs::read(&resource_path).unwrap(); let guess = mime_guess::from_path(resource_path); let mime = mime::Mime::from_str(guess.first().unwrap().essence_str()).unwrap(); - Ok(build_raw_response(raw_content, mime)) + return Ok(build_raw_response(raw_content, mime)); } else { // look for an uploaded file if let Some(sha256) = sha256 { @@ -340,12 +357,12 @@ async fn handle_request(request: Request) -> tide::Result { let metadata: FileMetadata = serde_json::from_reader(metadata_reader).unwrap(); let mime = mime::Mime::from_str(&metadata.content_type).unwrap(); - Ok(build_raw_response(raw_content, mime)) + return Ok(build_raw_response(raw_content, mime)); } else { - Ok(Response::builder(StatusCode::NotFound).build()) + return Ok(Response::builder(StatusCode::NotFound).build()); } } else { - Ok(Response::builder(StatusCode::NotFound).build()) + return Ok(Response::builder(StatusCode::NotFound).build()); } } } @@ -418,8 +435,7 @@ async fn handle_get_sites(request: Request) -> tide::Result { let sites = all_sites .iter() .filter_map(|s| { - let pk = s.1.config.get("pubkey")?; - if pk.as_str().unwrap() == key { + if s.1.config.pubkey.clone().unwrap() == key { Some(HashMap::from([("domain", s.0)])) } else { None @@ -437,8 +453,8 @@ async fn handle_list_request(request: Request) -> tide::Result let site_path = { if let Some(site) = get_site(&request) { let pubkey = request.param("pubkey").unwrap(); - if let Some(site_pubkey) = site.config.get("pubkey") { - if site_pubkey.as_str().unwrap() != pubkey { + if let Some(site_pubkey) = site.config.pubkey { + if site_pubkey != pubkey { log::info!("Invalid key."); return Ok(Response::builder(StatusCode::NotFound) .header("Access-Control-Allow-Origin", "*") @@ -494,8 +510,8 @@ async fn handle_upload_request(mut request: Request) -> tide::Result) -> tide::Result Result<(), std::io::Error> { femme::with_level(log::LevelFilter::Info); + let themes = theme::load_themes(); + + if themes.len() == 0 { + println!("No themes found. Exiting!"); + return Ok(()); + } + let sites; let existing_sites = site::load_sites(); @@ -640,6 +663,7 @@ async fn main() -> Result<(), std::io::Error> { let site_count = sites.len(); let mut app = tide::with_state(State { + themes: Arc::new(RwLock::new(themes)), sites: Arc::new(RwLock::new(sites)), }); diff --git a/src/resource.rs b/src/resource.rs new file mode 100644 index 0000000..830d77b --- /dev/null +++ b/src/resource.rs @@ -0,0 +1,263 @@ +use chrono::NaiveDateTime; +use http_types::mime; +use serde::Serialize; +use std::{collections::HashMap, env, fs::File, io::BufReader, str}; + +use crate::{ + content, nostr, + site::{ServusMetadata, Site}, +}; + +#[derive(Clone, Copy, PartialEq, Serialize)] +pub enum ResourceKind { + Post, + Page, + Note, +} + +#[derive(Clone, Serialize)] +pub enum ContentSource { + Event(String), + File(String), +} + +#[derive(Clone, Serialize)] +struct PageTemplateContext { + url: String, + slug: String, + summary: Option, + content: String, + date: Option, + #[serde(flatten)] + tags: HashMap, +} + +#[derive(Clone, Serialize)] +pub struct Resource { + pub kind: ResourceKind, + pub slug: String, + + pub title: Option, + pub date: Option, + + pub content_source: ContentSource, +} + +impl Resource { + pub fn read(&self, site: &Site) -> Option<(HashMap, String)> { + let filename = match self.content_source.clone() { + ContentSource::File(f) => f, + ContentSource::Event(e_id) => { + let events = site.events.read().unwrap(); + let event_ref = events.get(&e_id).unwrap(); + event_ref.filename.to_owned() + } + }; + let file = File::open(filename).unwrap(); + let mut reader = BufReader::new(file); + + content::read(&mut reader) + } + + pub fn get_resource_url(&self) -> Option { + // TODO: extract all URL patterns from config! + match self.kind { + ResourceKind::Post => Some(format!("/posts/{}", &self.slug)), + ResourceKind::Page => Some(format!("/{}", &self.clone().slug)), + ResourceKind::Note => Some(format!("/notes/{}", &self.clone().slug)), + } + } + + pub fn render(&self, site: &Site) -> Vec { + let (front_matter, content) = self.read(site).unwrap(); + + let mut tera = site.tera.write().unwrap(); + let mut extra_context = tera::Context::new(); + + // TODO: need real multilang support, + // but for now, we just set this so that Zola themes don't complain + extra_context.insert("lang", "en"); + + extra_context.insert("config", &site.config); + + // TODO: how to refactor this? + // Basically the if/else branches are the same, + // but constructing PageTemplateContext with different type parameters. + if let Some(event) = nostr::parse_event(&front_matter, &content) { + let mut tags = event.get_tags_hash(); + if !tags.contains_key("title") { + tags.insert("title".to_string(), "".to_string()); + } + extra_context.insert( + "page", + &PageTemplateContext { + slug: self.slug.to_owned(), + date: self.date, + tags, + content: md_to_html(&content), + summary: event.get_long_form_summary(), + url: self.get_resource_url().unwrap(), + }, + ); + } else { + extra_context.insert( + "page", + &PageTemplateContext { + slug: self.slug.to_owned(), + date: self.date, + tags: front_matter, + content: md_to_html(&content), + summary: None, + url: self.get_resource_url().unwrap(), + }, + ); + } + extra_context.insert("data", &site.data); + + let resources = site.resources.read().unwrap(); + let mut posts_list = resources + .values() + .collect::>() + .into_iter() + .filter(|r| r.kind == ResourceKind::Post) + .collect::>(); + posts_list.sort_by(|a, b| b.date.cmp(&a.date)); + extra_context.insert("posts", &posts_list); + + let rendered_text = render(&content, Some(extra_context.clone()), &mut tera); + let html = md_to_html(&rendered_text); + + render_template("page.html", &mut tera, &html, extra_context) + .as_bytes() + .to_vec() + } +} + +fn render_template( + template: &str, + tera: &mut tera::Tera, + content: &str, + extra_context: tera::Context, +) -> String { + let mut context = tera::Context::new(); + context.insert( + "servus", + &ServusMetadata { + version: env!("CARGO_PKG_VERSION").to_string(), + }, + ); + context.insert("content", &content); + context.extend(extra_context); + + tera.render(template, &context).unwrap() +} + +fn render_robots_txt(site_url: &str) -> (mime::Mime, String) { + let content = format!("User-agent: *\nSitemap: {}/sitemap.xml", site_url); + (mime::PLAIN, content) +} + +fn render_nostr_json(site: &Site) -> (mime::Mime, String) { + let content = format!( + "{{ \"names\": {{ \"_\": \"{}\" }} }}", + site.config.pubkey.clone().unwrap_or("".to_string()) + ); + (mime::JSON, content) +} + +fn render_sitemap_xml(site_url: &str, site: &Site) -> (mime::Mime, String) { + let mut response: String = "\n".to_owned(); + let resources = site.resources.read().unwrap(); + response.push_str("\n"); + for url in resources.keys() { + let mut url = url.trim_end_matches("/index").to_owned(); + if url == site_url && !url.ends_with('/') { + url.push('/'); + } + response.push_str(&format!(" {}\n", url)); + } + response.push_str(""); + + (mime::XML, response) +} + +fn render_atom_xml(site_url: &str, site: &Site) -> (mime::Mime, String) { + let mut response: String = "\n".to_owned(); + response.push_str("\n"); + response.push_str(&format!( + "{}\n", + &site.config.title.clone().unwrap_or("".to_string()) + )); + response.push_str(&format!( + "\n", + site_url + )); + response.push_str(&format!("\n", site_url)); + response.push_str(&format!("{}\n", site_url)); + let resources = site.resources.read().unwrap(); + for (url, resource) in &*resources { + if resource.date.is_some() { + if let Some((_, content)) = resource.read(site) { + response.push_str( + &format!( + " +{} + +{} +{}/{} +
{}
+
+", + resource.title.clone().unwrap_or("".to_string()), + &url, + &resource.date.unwrap(), + site_url, + resource.slug.clone(), + &md_to_html(&content).to_owned() + ) + .to_owned(), + ); + } + } + } + response.push_str("
"); + + (mime::XML, response) +} + +pub fn render_standard_resource(resource_name: &str, site: &Site) -> Option<(mime::Mime, String)> { + match resource_name { + "robots.txt" => Some(render_robots_txt(&site.config.base_url)), + ".well-known/nostr.json" => Some(render_nostr_json(site)), + "sitemap.xml" => Some(render_sitemap_xml(&site.config.base_url, site)), + "atom.xml" => Some(render_atom_xml(&site.config.base_url, site)), + _ => None, + } +} + +fn md_to_html(md_content: &str) -> String { + let options = &markdown::Options { + compile: markdown::CompileOptions { + allow_dangerous_html: true, + ..markdown::CompileOptions::default() + }, + ..markdown::Options::default() + }; + + markdown::to_html_with_options(md_content, options).unwrap() +} + +fn render(content: &str, extra_context: Option, tera: &mut tera::Tera) -> String { + let mut context = tera::Context::new(); + context.insert( + "servus", + &ServusMetadata { + version: env!("CARGO_PKG_VERSION").to_string(), + }, + ); + if let Some(c) = extra_context { + context.extend(c); + } + + tera.render_str(content, &context).unwrap() +} diff --git a/src/sass.rs b/src/sass.rs new file mode 100644 index 0000000..353c52f --- /dev/null +++ b/src/sass.rs @@ -0,0 +1,50 @@ +// * Code taken from [Zola](https://www.getzola.org/) and adapted. +// * Zola's MIT license applies. See: https://github.com/getzola/zola/blob/master/LICENSE + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use globset::Glob; +use grass::{from_path as compile_file, Options, OutputStyle}; +use walkdir::{DirEntry, WalkDir}; + +// https://github.com/getzola/zola/blob/master/components/site/src/sass.rs + +pub fn compile_sass(sass_path: &PathBuf) -> HashMap { + let mut resources = HashMap::new(); + + let options = Options::default().style(OutputStyle::Compressed); + let files = get_non_partial_scss(&sass_path); + + for file in files { + let css = compile_file(&file, &options).unwrap(); + + let path = file.strip_prefix(&sass_path).unwrap().with_extension("css"); + + resources.insert(format!("/{}", path.display().to_string()), css); + } + + resources +} + +fn is_partial_scss(entry: &DirEntry) -> bool { + entry + .file_name() + .to_str() + .map(|s| s.starts_with('_')) + .unwrap_or(false) +} + +fn get_non_partial_scss(sass_path: &Path) -> Vec { + let glob = Glob::new("*.{sass,scss}") + .expect("Invalid glob for sass") + .compile_matcher(); + + WalkDir::new(sass_path) + .into_iter() + .filter_entry(|e| !is_partial_scss(e)) + .filter_map(|e| e.ok()) + .map(|e| e.into_path()) + .filter(|e| glob.is_match(e)) + .collect::>() +} diff --git a/src/site.rs b/src/site.rs index 8f48ccc..52e6e74 100644 --- a/src/site.rs +++ b/src/site.rs @@ -1,9 +1,8 @@ use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Utc}; -use http_types::mime; use serde::{Deserialize, Serialize}; use std::{ collections::HashMap, - env, fs, + fs, fs::File, io::BufReader, path::PathBuf, @@ -13,36 +12,79 @@ use std::{ use tide::log; use walkdir::WalkDir; -use crate::{content, nostr}; +const DEFAULT_THEME: &str = "hyde"; -mod default_theme { - include!(concat!(env!("OUT_DIR"), "/default_theme.rs")); -} +use crate::{ + content, nostr, + resource::{ContentSource, Resource, ResourceKind}, + template, +}; #[derive(Clone, Serialize, Deserialize)] -struct ServusMetadata { - version: String, -} - -#[derive(Clone, Serialize)] -struct PageTemplateContext { - url: String, - slug: String, - summary: Option, - inner_html: String, - date: Option, - #[serde(flatten)] - tags: HashMap, +pub struct ServusMetadata { + pub version: String, } #[derive(Clone)] pub struct Site { pub path: String, - pub config: toml::Value, + pub config: SiteConfig, pub data: Arc>>, pub events: Arc>>, pub resources: Arc>>, - pub tera: Arc>, + pub tera: Arc>, // TODO: try to move this to Theme +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct SiteConfig { + pub base_url: String, + pub pubkey: Option, + + pub theme: Option, + pub title: Option, + + pub extra: HashMap, +} + +impl SiteConfig { + // https://github.com/getzola/zola/blob/master/components/config/src/config/mod.rs + + /// Makes a url, taking into account that the base url might have a trailing slash + pub fn make_permalink(&self, path: &str) -> String { + let trailing_bit = if path.ends_with('/') || path.ends_with("atom.xml") || path.is_empty() { + "" + } else { + "/" + }; + + // Index section with a base url that has a trailing slash + if self.base_url.ends_with('/') && path == "/" { + self.base_url.to_string() + } else if path == "/" { + // index section with a base url that doesn't have a trailing slash + format!("{}/", self.base_url) + } else if self.base_url.ends_with('/') && path.starts_with('/') { + format!("{}{}{}", self.base_url, &path[1..], trailing_bit) + } else if self.base_url.ends_with('/') || path.starts_with('/') { + format!("{}{}{}", self.base_url, path, trailing_bit) + } else { + format!("{}/{}{}", self.base_url, path, trailing_bit) + } + } +} + +fn load_templates(site_config: &SiteConfig) -> tera::Tera { + println!("Loading templates..."); + + let theme_path = format!("./themes/{}", site_config.theme.as_ref().unwrap()); + + let mut tera = tera::Tera::new(&format!("{}/templates/**/*", theme_path)).unwrap(); + tera.autoescape_on(vec![]); + tera.register_function("get_url", template::GetUrl::new(site_config.clone())); + + println!("Loaded {} templates!", tera.get_template_names().count()); + + tera } impl Site { @@ -169,7 +211,7 @@ impl Site { slug, content_source, }; - if let Some(url) = resource.get_resource_url(&self.config) { + if let Some(url) = resource.get_resource_url() { println!("Resource: url={}.", &url); let mut resources = self.resources.write().unwrap(); resources.insert(url, resource); @@ -248,7 +290,7 @@ impl Site { content_source: ContentSource::Event(event.id.to_owned()), }; - if let Some(url) = resource.get_resource_url(&self.config) { + if let Some(url) = resource.get_resource_url() { // but not all posts have an URL (drafts don't) let mut resources = self.resources.write().unwrap(); resources.insert(url.to_owned(), resource); @@ -360,13 +402,6 @@ impl Site { } } -#[derive(Clone, Copy, PartialEq, Serialize)] -pub enum ResourceKind { - Post, - Page, - Note, -} - #[derive(Clone, Serialize)] pub struct EventRef { pub id: String, @@ -376,23 +411,6 @@ pub struct EventRef { pub filename: String, } -#[derive(Clone, Serialize)] -pub enum ContentSource { - Event(String), - File(String), -} - -#[derive(Clone, Serialize)] -pub struct Resource { - pub kind: ResourceKind, - pub slug: String, - - pub title: Option, - pub date: Option, - - pub content_source: ContentSource, -} - impl EventRef { pub fn read(&self) -> Option<(HashMap, String)> { let file = File::open(&self.filename).unwrap(); @@ -402,265 +420,11 @@ impl EventRef { } } -impl Resource { - pub fn read(&self, site: &Site) -> Option<(HashMap, String)> { - let filename = match self.content_source.clone() { - ContentSource::File(f) => f, - ContentSource::Event(e_id) => { - let events = site.events.read().unwrap(); - let event_ref = events.get(&e_id).unwrap(); - event_ref.filename.to_owned() - } - }; - let file = File::open(filename).unwrap(); - let mut reader = BufReader::new(file); - - content::read(&mut reader) - } - - pub fn get_resource_url(&self, site_config: &toml::Value) -> Option { - // TODO: extract all URL patterns from config! - match self.kind { - ResourceKind::Post => { - return Some(site_config.get("post_permalink").map_or_else( - || format!("/posts/{}", &self.slug), - |p| p.as_str().unwrap().replace(":slug", &self.slug), - )); - } - ResourceKind::Page => Some(format!("/{}", &self.clone().slug)), - ResourceKind::Note => Some(format!("/notes/{}", &self.clone().slug)), - } - } - - pub fn render(&self, site: &Site) -> Vec { - let (front_matter, content) = self.read(site).unwrap(); - - match self.kind { - ResourceKind::Page | ResourceKind::Post => { - let mut tera = site.tera.write().unwrap(); - let mut extra_context = tera::Context::new(); - - // TODO: how to refactor this? - // Basically the if/else branches are the same, - // but constructing PageTemplateContext with different type parameters. - if let Some(event) = nostr::parse_event(&front_matter, &content) { - extra_context.insert( - "resource", - &PageTemplateContext { - slug: self.slug.to_owned(), - date: self.date, - tags: event.get_tags_hash(), - inner_html: md_to_html(&content), - summary: event.get_long_form_summary(), - url: self.get_resource_url(&site.config).unwrap(), - }, - ); - } else { - extra_context.insert( - "resource", - &PageTemplateContext { - slug: self.slug.to_owned(), - date: self.date, - tags: front_matter, - inner_html: md_to_html(&content), - summary: None, - url: self.get_resource_url(&site.config).unwrap(), - }, - ); - } - extra_context.insert("data", &site.data); - - let resources = site.resources.read().unwrap(); - let mut posts_list = resources - .values() - .collect::>() - .into_iter() - .filter(|r| r.kind == ResourceKind::Post) - .collect::>(); - posts_list.sort_by(|a, b| b.date.cmp(&a.date)); - extra_context.insert("posts", &posts_list); - - let rendered_text = render( - &content, - &site.config, - Some(extra_context.clone()), - &mut tera, - ); - let html = md_to_html(&rendered_text); - let layout = match self.kind { - ResourceKind::Post => "post.html".to_string(), - _ => "page.html".to_string(), - }; - - render_template(&layout, &mut tera, &html, &site.config, extra_context) - .as_bytes() - .to_vec() - } - ResourceKind::Note => { - let mut tera = site.tera.write().unwrap(); - let mut extra_context = tera::Context::new(); - let date = self.date; - extra_context.insert( - "resource", - &PageTemplateContext { - slug: self.slug.to_owned(), - date, - tags: front_matter, - inner_html: content.to_owned(), - summary: None, - url: self.get_resource_url(&site.config).unwrap(), - }, - ); - render_template( - "note.html", - &mut tera, - &content, - &site.config, - extra_context, - ) - .as_bytes() - .to_vec() - } - } - } -} - -fn render_template( - template: &str, - tera: &mut tera::Tera, - content: &str, - site_config: &toml::Value, - extra_context: tera::Context, -) -> String { - let mut context = tera::Context::new(); - context.insert("site", &site_config); - context.insert( - "servus", - &ServusMetadata { - version: env!("CARGO_PKG_VERSION").to_string(), - }, - ); - context.insert("content", &content); - context.extend(extra_context); - - tera.render(template, &context).unwrap() -} - -fn md_to_html(md_content: &str) -> String { - let options = &markdown::Options { - compile: markdown::CompileOptions { - allow_dangerous_html: true, - ..markdown::CompileOptions::default() - }, - ..markdown::Options::default() - }; - - markdown::to_html_with_options(md_content, options).unwrap() -} - -fn render( - content: &str, - site: &toml::Value, - extra_context: Option, - tera: &mut tera::Tera, -) -> String { - let mut context = tera::Context::new(); - context.insert("site", &site); - context.insert( - "servus", - &ServusMetadata { - version: env!("CARGO_PKG_VERSION").to_string(), - }, - ); - if let Some(c) = extra_context { - context.extend(c); - } - - tera.render_str(content, &context).unwrap() -} - -fn render_robots_txt(site_url: &str) -> (mime::Mime, String) { - let content = format!("User-agent: *\nSitemap: {}/sitemap.xml", site_url); - (mime::PLAIN, content) -} - -fn render_nostr_json(site: &Site) -> (mime::Mime, String) { - let content = format!( - "{{ \"names\": {{ \"_\": \"{}\" }} }}", - site.config.get("pubkey").unwrap().as_str().unwrap() - ); - (mime::JSON, content) -} - -fn render_sitemap_xml(site_url: &str, site: &Site) -> (mime::Mime, String) { - let mut response: String = "\n".to_owned(); - let resources = site.resources.read().unwrap(); - response.push_str("\n"); - for url in resources.keys() { - let mut url = url.trim_end_matches("/index").to_owned(); - if url == site_url && !url.ends_with('/') { - url.push('/'); - } - response.push_str(&format!(" {}\n", url)); - } - response.push_str(""); - - (mime::XML, response) -} - -fn render_atom_xml(site_url: &str, site: &Site) -> (mime::Mime, String) { - let site_title = match site.config.get("title") { - Some(t) => t.as_str().unwrap(), - _ => "", - }; - let mut response: String = "\n".to_owned(); - response.push_str("\n"); - response.push_str(&format!("{}\n", site_title)); - response.push_str(&format!( - "\n", - site_url - )); - response.push_str(&format!("\n", site_url)); - response.push_str(&format!("{}\n", site_url)); - let resources = site.resources.read().unwrap(); - for (url, resource) in &*resources { - if resource.date.is_some() { - if let Some((_, content)) = resource.read(site) { - response.push_str( - &format!( - " -{} - -{} -{}/{} -
{}
-
-", - resource.title.clone().unwrap_or("".to_string()), - &url, - &resource.date.unwrap(), - site_url, - resource.slug.clone(), - &md_to_html(&content).to_owned() - ) - .to_owned(), - ); - } - } - } - response.push_str("
"); - - (mime::XML, response) -} - -pub fn render_standard_resource(resource_name: &str, site: &Site) -> Option<(mime::Mime, String)> { - let site_url = site.config.get("url")?.as_str().unwrap(); - match resource_name { - "robots.txt" => Some(render_robots_txt(site_url)), - ".well-known/nostr.json" => Some(render_nostr_json(site)), - "sitemap.xml" => Some(render_sitemap_xml(site_url, site)), - "atom.xml" => Some(render_atom_xml(site_url, site)), - _ => None, +pub fn load_config(config_path: &str) -> Option { + if let Ok(content) = fs::read_to_string(config_path) { + Some(toml::from_str(&content).unwrap()) + } else { + None } } @@ -673,35 +437,26 @@ pub fn load_sites() -> HashMap { let mut sites = HashMap::new(); for path in &paths { println!("Found site: {}", path.file_name().to_str().unwrap()); - let config_content = - match fs::read_to_string(&format!("{}/_config.toml", path.path().display())) { - Ok(content) => content, - _ => { - println!( - "No site config for site: {}. Skipping!", - path.file_name().to_str().unwrap() - ); - continue; - } - }; - println!("Loading layouts..."); + let site_path = path.path().display().to_string(); + + let config = load_config(&format!("{}/_config.toml", site_path)); + if config.is_none() { + println!("No site config for site: {}. Skipping!", site_path); + } + + let mut config = config.unwrap(); - let mut tera = tera::Tera::new(&format!( - "{}/_layouts/**/*", - fs::canonicalize(path.path()).unwrap().display() - )) - .unwrap(); - tera.autoescape_on(vec![]); + let theme_path = format!("./themes/{}", config.theme.as_ref().unwrap()); + let theme_config = load_config(&&format!("{}/config.toml", theme_path)); - println!("Loaded {} templates!", tera.get_template_names().count()); + config.extra = theme_config.unwrap().extra; // TODO: merge rather than overwrite! - let config: HashMap = toml::from_str(&config_content).unwrap(); - let site_config = config.get("site").unwrap(); + let tera = load_templates(&config); let site = Site { - config: site_config.clone(), - path: path.path().display().to_string(), + config, + path: site_path, data: Arc::new(RwLock::new(HashMap::new())), events: Arc::new(RwLock::new(HashMap::new())), resources: Arc::new(RwLock::new(HashMap::new())), @@ -724,30 +479,28 @@ pub fn create_site(domain: &str, admin_pubkey: Option) -> Site { let path = format!("./sites/{}", domain); fs::create_dir_all(&path).unwrap(); - default_theme::generate(&format!("./sites/{}/", domain)); - - let mut tera = tera::Tera::new(&format!("{}/_layouts/**/*", path)).unwrap(); - tera.autoescape_on(vec![]); - let config_content = format!( - "[site]\npubkey = \"{}\"\nurl = \"https://{}\"\ntitle = \"{}\"\ntagline = \"{}\"", + "pubkey = \"{}\"\nbase_url = \"https://{}\"\ntitle = \"{}\"\ntheme = \"{}\"\n[extra]\n", admin_pubkey.unwrap_or("".to_string()), domain, - "Untitled site", // TODO: get from the request? - "Undefined tagline" + "", + DEFAULT_THEME ); fs::write(format!("./sites/{}/_config.toml", domain), &config_content).unwrap(); - let site_config = toml::from_str::>(&config_content).unwrap(); + let config = load_config(&format!("{}/_config.toml", path)).unwrap(); + + let tera = load_templates(&config); let site = Site { - config: site_config.get("site").unwrap().clone(), + config, path, data: Arc::new(RwLock::new(HashMap::new())), events: Arc::new(RwLock::new(HashMap::new())), resources: Arc::new(RwLock::new(HashMap::new())), tera: Arc::new(RwLock::new(tera)), }; + site.load_resources(); site diff --git a/src/template.rs b/src/template.rs new file mode 100644 index 0000000..acf8863 --- /dev/null +++ b/src/template.rs @@ -0,0 +1,79 @@ +// * Code taken from [Zola](https://www.getzola.org/) and adapted. +// * Zola's MIT license applies. See: https://github.com/getzola/zola/blob/master/LICENSE + +use std::collections::HashMap; +use tera::{from_value, to_value, Function as TeraFn, Result as TeraResult, Value as TeraValue}; + +use crate::site::SiteConfig; + +// https://github.com/getzola/zola/blob/master/components/templates/src/global_fns/macros.rs + +macro_rules! required_arg { + ($ty: ty, $e: expr, $err: expr) => { + match $e { + Some(v) => match from_value::<$ty>(v.clone()) { + Ok(u) => u, + Err(_) => return Err($err.into()), + }, + None => return Err($err.into()), + } + }; +} + +macro_rules! optional_arg { + ($ty: ty, $e: expr, $err: expr) => { + match $e { + Some(v) => match from_value::<$ty>(v.clone()) { + Ok(u) => Some(u), + Err(_) => return Err($err.into()), + }, + None => None, + } + }; +} + +// https://github.com/getzola/zola/blob/master/components/templates/src/global_fns/files.rs + +pub struct GetUrl { + site_config: SiteConfig, +} + +impl GetUrl { + pub fn new(site_config: SiteConfig) -> Self { + Self { site_config } + } +} + +impl TeraFn for GetUrl { + fn call(&self, args: &HashMap) -> TeraResult { + let path = required_arg!( + String, + args.get("path"), + "`get_url` requires a `path` argument with a string value" + ); + let trailing_slash = optional_arg!( + bool, + args.get("trailing_slash"), + "`get_url`: `trailing_slash` must be a boolean (true or false)" + ) + .unwrap_or(false); + + // anything else + let mut segments = vec![]; + + segments.push(path); + + let path = segments.join("/"); + + let mut permalink = self.site_config.make_permalink(&path); + if !trailing_slash && permalink.ends_with('/') { + permalink.pop(); // Removes the slash + } + + Ok(to_value(permalink).unwrap()) + } + + fn is_safe(&self) -> bool { + true + } +} diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..b8398d5 --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,69 @@ +use std::{ + collections::HashMap, + fs, + path::PathBuf, + sync::{Arc, RwLock}, +}; + +use crate::sass; +use crate::site::{load_config, SiteConfig}; + +#[derive(Clone)] +pub struct Theme { + pub path: String, + pub config: SiteConfig, + pub resources: Arc>>, +} + +impl Theme { + pub fn load_sass(&self) { + let mut sass_path = PathBuf::from(&self.path); + sass_path.push("sass/"); + if !sass_path.as_path().exists() { + return; + } + + let mut resources = self.resources.write().unwrap(); + + for (k, v) in &sass::compile_sass(&sass_path) { + println!("Loaded theme resource: {}", k); + resources.insert(k.to_owned(), v.to_string()); + } + } +} + +pub fn load_themes() -> HashMap { + let paths = match fs::read_dir("./themes") { + Ok(paths) => paths.map(|r| r.unwrap()).collect(), + _ => vec![], + }; + + let mut themes = HashMap::new(); + for path in &paths { + println!("Found theme: {}", path.file_name().to_str().unwrap()); + + let theme_path = path.path().display().to_string(); + + let config = load_config(&format!("{}/config.toml", theme_path)); + if config.is_none() { + println!("No config for theme: {}. Skipping!", theme_path); + } + let config = config.unwrap(); + + let theme = Theme { + path: theme_path, + config, + resources: Arc::new(RwLock::new(HashMap::new())), + }; + + theme.load_sass(); + + println!("Theme loaded!"); + + themes.insert(path.file_name().to_str().unwrap().to_string(), theme); + } + + println!("{} themes loaded!", themes.len()); + + themes +} diff --git a/themes/default/_content/pages/index.md b/themes/default/_content/pages/index.md deleted file mode 100644 index 363b59e..0000000 --- a/themes/default/_content/pages/index.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: '' ---- - -Welcome to {{ site.title }}! diff --git a/themes/default/_content/pages/posts.md b/themes/default/_content/pages/posts.md deleted file mode 100644 index 9c9b208..0000000 --- a/themes/default/_content/pages/posts.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: Posts ---- - -{% for post in posts %} -* [{{ post.title }}](/posts/{{ post.slug }}) on {{ post.date | date(format='%d %B %Y') }} -{% endfor %} diff --git a/themes/default/_layouts/base.html b/themes/default/_layouts/base.html deleted file mode 100644 index 9c4f9bd..0000000 --- a/themes/default/_layouts/base.html +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - - - - - - - {% if resource and resource.title %} - - - {{ resource.title }} | {{ site.title }} - - - - {% else %} - {{ site.title }} | {{ site.tagline }} - - - - {% endif %} - - - - - - - - -
- {% block content %} - {% endblock content %} -
- - - diff --git a/themes/default/_layouts/note.html b/themes/default/_layouts/note.html deleted file mode 100644 index 2ebdaba..0000000 --- a/themes/default/_layouts/note.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
- {{ content }} -
-{% endblock %} diff --git a/themes/default/_layouts/page.html b/themes/default/_layouts/page.html deleted file mode 100644 index 413a094..0000000 --- a/themes/default/_layouts/page.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
-

{{ resource.title }}

- {{ content }} -
-{% endblock %} diff --git a/themes/default/_layouts/post.html b/themes/default/_layouts/post.html deleted file mode 100644 index 7050aac..0000000 --- a/themes/default/_layouts/post.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends "base.html" %} - -{% block content %} -
- -

{{ resource.title }}

- {{ content }} -
-{% endblock %} diff --git a/themes/default/style.css b/themes/default/style.css deleted file mode 100644 index bf0b7c4..0000000 --- a/themes/default/style.css +++ /dev/null @@ -1,276 +0,0 @@ -* { - -webkit-box-sizing: border-box; - -moz-box-sizing: border-box; - box-sizing: border-box; -} - -html, -body { - margin: 0; - padding: 0; -} - -html { - font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen-Sans,Ubuntu,Cantarell,Open Sans,Helvetica Neue,sans-serif; - font-size: 16px; - line-height: 1.5; -} - -@media (min-width: 38em) { - html { - font-size: 20px; - } -} - -@media (min-width: 48em) { - html { - font-size: 16px; - } -} - -@media (min-width: 58em) { - html { - font-size: 20px; - } -} - -body { - color: #515151; - background-color: #fff; - -webkit-text-size-adjust: 100%; - -ms-text-size-adjust: 100%; -} - -a { - color: #268bd2; - text-decoration: none; -} -a strong { - color: inherit; -} - -a:hover, -a:focus { - text-decoration: underline; -} - -h1, h2, h3, h4, h5, h6 { - margin-bottom: .5rem; - font-weight: bold; - line-height: 1.25; - color: #313131; - text-rendering: optimizeLegibility; -} -h1 { - font-size: 2rem; -} -h2 { - margin-top: 1rem; - font-size: 1.5rem; -} -h3 { - margin-top: 1.5rem; - font-size: 1.25rem; -} -h4, h5, h6 { - margin-top: 1rem; - font-size: 1rem; -} - -p { - margin-top: 0; - margin-bottom: 1rem; -} - -strong { - color: #303030; -} - -ul, ol, dl { - margin-top: 0; - margin-bottom: 1rem; -} - -dt { - font-weight: bold; -} -dd { - margin-bottom: .5rem; -} - -hr { - position: relative; - margin: 1.5rem 0; - border: 0; - border-top: 1px solid #eee; - border-bottom: 1px solid #fff; -} - -abbr { - font-size: 85%; - font-weight: bold; - color: #555; - text-transform: uppercase; -} -abbr[title] { - cursor: help; - border-bottom: 1px dotted #e5e5e5; -} - -code, -pre { - font-family: Menlo, Monaco, "Courier New", monospace; -} -code { - padding: .25em .5em; - font-size: 85%; - color: #bf616a; - background-color: #f9f9f9; - border-radius: 3px; -} -pre { - display: block; - margin-top: 0; - margin-bottom: 1rem; - padding: 1rem; - font-size: .8rem; - line-height: 1.4; - white-space: pre; - white-space: pre-wrap; - word-break: break-all; - word-wrap: break-word; - background-color: #f9f9f9; -} -pre code { - padding: 0; - font-size: 100%; - color: inherit; - background-color: transparent; -} - -blockquote { - padding: .5rem 1rem; - margin: .8rem 0; - color: #7a7a7a; - border-left: .25rem solid #e5e5e5; -} -blockquote p:last-child { - margin-bottom: 0; -} -@media (min-width: 30em) { - blockquote { - padding-right: 5rem; - padding-left: 1.25rem; - } -} - -img { - display: block; - max-width: 100%; - margin: 0 0 1rem; - border-radius: 5px; -} - -table { - margin-bottom: 1rem; - width: 100%; - border: 1px solid #e5e5e5; - border-collapse: collapse; -} -td, -th { - padding: .25rem .5rem; - border: 1px solid #e5e5e5; -} -tbody tr:nth-child(odd) td, -tbody tr:nth-child(odd) th { - background-color: #f9f9f9; -} - -.content { - padding-top: 4rem; - padding-bottom: 4rem; -} - -@media (min-width: 48em) { - .content { - max-width: 38rem; - margin-left: 20rem; - margin-right: 2rem; - } -} - -@media (min-width: 64em) { - .content { - margin-left: 15rem; - margin-right: 4rem; - } -} - -.navbar { - display: inline-block; - width: 100%; - text-align: center; -} - -.tooltip { - position: relative; - display: inline-block; - border-bottom: 1px dotted black; -} - -.tooltip .tooltiptext { - visibility: hidden; - width: 170px; - background-color: black; - color: #fff; - text-align: center; - border-radius: 6px; - padding: 5px 0; - - /* Position the tooltip */ - position: absolute; - z-index: 1; - top: 100%; - left: 100%; -} - -.tooltip:hover .tooltiptext { - visibility: visible; -} - -/***** BEGIN dark mode *****/ -/***** https://ar.al/2021/08/24/implementing-dark-mode-in-a-handful-of-lines-of-css-with-css-filters/ *****/ - -@media (prefers-color-scheme: dark) { - body { - filter: invert(100%) hue-rotate(180deg); - } - - /* Firefox workaround: Set the background colour for the html element separately. */ - html { - background-color: #111; - } - - /* Do not invert media (revert the invert). */ - img, video, iframe, .flag { - filter: invert(100%) hue-rotate(180deg); - } - - /* Improve contrast on icons. */ - .icon { - filter: invert(15%) hue-rotate(180deg); - } - - /* Re-enable code block backgrounds. */ - pre { - filter: invert(6%); - } - - /* Improve contrast on list item markers. */ - li::marker { - color: #666; - } -} - -/***** END dark mode *****/