From 96dee7eae19db26ed81cf1760ee5c12c81b19cdf Mon Sep 17 00:00:00 2001 From: "Anthony J. Martinez" Date: Sun, 11 Jul 2021 11:38:58 +0000 Subject: [PATCH] Update deps, implement RSS feed, rev 0.11.0 --- Cargo.lock | 21 ++--- Cargo.toml | 1 + README.md | 6 +- src/config.rs | 6 +- src/render.rs | 118 +++++++++++++++++++------ src/render/default.rs | 1 + src/routes.rs | 27 ++++-- test_files/site/templates/special.tmpl | 1 + test_files/test-config.toml | 1 + 9 files changed, 136 insertions(+), 46 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b4a84a6..08ac57b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -22,15 +22,16 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.41" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15af2628f6890fe2609a3b91bef4c83450512802e59489f9c1cb1fa5df064a61" +checksum = "595d3cfa7a60d4555cb5067b99f07142a08ea778de5cf993f7b75c7d8fabc486" [[package]] name = "arse" version = "0.11.0" dependencies = [ "anyhow", + "chrono", "clap", "glob", "hyper", @@ -455,9 +456,9 @@ checksum = "02296996cb8796d7c6e3bc2d9211b7802812d36999a51bb754123ead7d37d026" [[package]] name = "hyper" -version = "0.14.9" +version = "0.14.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07d6baa1b441335f3ce5098ac421fb6547c46dda735ca1bc6d0153c838f9dd83" +checksum = "7728a72c4c7d72665fde02204bcbd93b247721025b222ef78606f14513e0fd03" dependencies = [ "bytes", "futures-channel", @@ -534,9 +535,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.97" +version = "0.2.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12b8adadd720df158f4d70dfe7ccc6adb0472d7c55ca83445f6a5ab3e36f8fb6" +checksum = "320cfe77175da3a483efed4bc0adc1968ca050b098ce4f2f1c13a56626128790" [[package]] name = "lock_api" @@ -1092,9 +1093,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "570c2eb13b3ab38208130eccd41be92520388791207fde783bda7c1e8ace28d4" +checksum = "98c8b05dc14c75ea83d63dd391100353789f5f24b8b3866542a5e85c8be8e985" dependencies = [ "autocfg", "bytes", @@ -1112,9 +1113,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "1.2.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c49e3df43841dafb86046472506755d8501c5615673955f6aa17181125d13c37" +checksum = "54473be61f4ebe4efd09cec9bd5d16fa51d70ea0192213d754d2d500457db110" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 86183d9..fd190d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ categories = ["command-line-utilities", "web-programming::http-server"] [dependencies] anyhow = "1.0" +chrono = "0.4" clap = "2.33.3" glob = "0.3.0" hyper = "0.14" diff --git a/README.md b/README.md index 97346d7..30b4cf5 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ and flexible base for serving sites using: * [pulldown-cmark](https://crates.io/crates/pulldown-cmark) for CommonMark rendering * [routerify](https://crates.io/crates/routerify) to serve the site * [simplecss](https://simplecss.org) for default styling +* [rss](https://crates.io/crates/rss) for generating a full-site RSS feed ## Usage @@ -36,6 +37,7 @@ An example config, as generated, is shown below: [site] name = 'Example Site' author = 'Arthur Writeson' +url = 'https://www.example.com' template = 'default.tmpl' topics = [ 'one', @@ -76,7 +78,9 @@ The following elements are available within the Tera context for rendering: * Items in `[docpaths]` are generated as full paths for completeness, however relative paths will work if desired * From the example above the user is free to simply use `site/templates` and `site/webroot` and move the directory out of `/home/user` * Note that `arse new` creates the site tree, and all other output files, in the current working directory. - * If `gallery` is one of the topics requested, a simple image slideshow will be generated for `/gallery/ext/*.jpg` with the same lexical reverse order as posts. +* If `gallery` is one of the topics requested + * A simple image slideshow will be generated for `/gallery/ext/*.jpg` + * Display will follow the same lexical reverse order as posts. ## Path to 1.0 diff --git a/src/config.rs b/src/config.rs index ed64d65..524a922 100644 --- a/src/config.rs +++ b/src/config.rs @@ -131,6 +131,7 @@ fn csv_to_vec(csv: &str) -> Vec { pub(crate) struct Site { pub name: String, pub author: String, + pub url: String, pub template: String, pub topics: Vec, } @@ -140,10 +141,11 @@ impl Site { pub(crate) fn new_from_input(reader: &mut R) -> Result { let name = get_input("Please enter a name for the site: ", reader)?; let author = get_input("Please enter the site author's name: ", reader)?; + let url = get_input("Please enter the base URL for your site: ", reader)?; let topics = get_input("Please enter comma-separated site topics: ", reader)?; let topics = csv_to_vec(&topics); let template = "default.tmpl".to_owned(); - let site = Site { name, author, template, topics }; + let site = Site { name, author, url, template, topics }; trace!("Site: {:?}", site); Ok(site) @@ -275,7 +277,7 @@ mod tests { fn build_config_from_input() { let dir = tempfile::tempdir().unwrap(); // Setup all target fields - let mut src: &[u8] = b"Site Name\nAuthor Name\nOne, Two, Three, And More\n"; + let mut src: &[u8] = b"Site Name\nAuthor Name\nhttps://my.example.site\nOne, Two, Three, And More\n"; let config = AppConfig::generate(&dir, &mut src); assert!(config.is_ok()); diff --git a/src/render.rs b/src/render.rs index 3ef0bef..3717412 100644 --- a/src/render.rs +++ b/src/render.rs @@ -10,6 +10,7 @@ copied, modified, or distributed except according to those terms. //! Provides the rendering engine for topics and posts using [`AppConfig`], [`Tera`], and [`pulldown_cmark`]. +use std::fs::File; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -17,9 +18,10 @@ use super::config::AppConfig; use super::common; use super::{Context, Result}; +use chrono::{DateTime, Utc}; use log::{debug, trace}; use pulldown_cmark::{Parser, html}; -use rss::{Channel, ChannelBuilder, Item, ItemBuilder}; +use rss::{Channel, Item}; use tera::{Tera, Context as TemplateContext}; /// Static defaults for the rendering engine. @@ -150,7 +152,6 @@ impl Engine { Self::read_post_to_html(post_path) } - fn read_post_to_html>(path: P) -> Result { debug!("Rendering Post Markdown to HTML"); trace!("Rendering {} to HTML", &path.as_ref().display()); @@ -163,36 +164,59 @@ impl Engine { Ok(html_output) } - /// Generates an RSS feed of + /// Renders `/rss.xml` for all topics pub(crate) fn rss(&self) -> Result { debug!("Rendering RSS Feed"); let site = &self.app.site; let items = Self::rss_items(&self)?; - let channel = ChannelBuilder::default() - .title(&site.name) - .link("stuff") // TODO: add an item to config::Site to hold the domain name - .description(format!("{} RSS Feed", &site.name)) - .items(items) - .build() - .unwrap(); + let mut channel = Channel::default(); + channel.set_title(&site.name); + channel.set_link(&site.url); + channel.set_description(format!("{} RSS Feed", &site.name)); + channel.set_items(items); Ok(channel.to_string()) } fn rss_items(&self) -> Result> { - // TODO: Actually implement this in a meangingful way - /* - Need to loop across the topics and their contents and build Item - entities to push into a vector. Probably need to also get post - modification times. - - Need to come up with some way to define the title/description. - */ - let item1 = Item::default(); + debug!("Building RSS Items"); + let mut items: Vec = Vec::new(); + items.append(&mut Self::topic_to_item(&self, "main")?); + + for topic in &self.app.site.topics { + let mut topic_items = Self::topic_to_item(&self, &common::slugify(&topic))?; + items.append(&mut topic_items); + } - let item2 = Item::default(); + Ok(items) + } + + fn topic_to_item(&self, topic_slug: &str) -> Result> { + trace!("Generating RSS Items for topic: {}", &topic_slug); + let mut items: Vec = Vec::new(); + let topic_path = Path::new(&self.app.docpaths.webroot).join(topic_slug).join("posts"); + let pat = format!("{}/*.md", topic_path.display()); + let paths = common::path_matches(&pat)?; + for path in paths { + trace!("Generating RSS Item for post at: {}", path.display()); + let link = format!("{}/{}/{}", + &self.app.site.url, + path.strip_prefix(&self.app.docpaths.webroot)?.parent().unwrap().to_str().unwrap(), + path.file_stem().unwrap().to_str().unwrap()); + let f = File::open(&path)?; + let updated = f.metadata()?.modified()?; + let updated: DateTime = updated.into(); + let updated = updated.to_rfc2822(); + + let description = Self::read_post_to_html(path)?; + + let mut item = Item::default(); + item.set_link(link); + item.set_pub_date(updated); + item.set_description(description.to_owned()); + items.push(item); + } - let items = vec![item1, item2]; Ok(items) } } @@ -206,7 +230,7 @@ mod tests { #[test] fn check_default_template() { let dir = tempfile::tempdir().unwrap(); - let mut src: &[u8] = b"Site Name\nAuthor Name\nOne, Two, Three, And More\nadmin\n"; + let mut src: &[u8] = b"Site Name\nAuthor Name\nhttps://special.example.site\nOne, Gallery\nadmin\n"; let config = AppConfig::generate(&dir, &mut src).unwrap(); let config = Arc::new(config); let tera = Engine::load_template(config); @@ -216,7 +240,7 @@ mod tests { #[test] fn check_render_post() { let dir = tempfile::tempdir().unwrap(); - let mut src: &[u8] = b"Site Name\nAuthor Name\nOne, Two, Three, And More\nadmin\n"; + let mut src: &[u8] = b"Site Name\nAuthor Name\nhttps://special.example.site\nOne, Gallery\nadmin\n"; let config = AppConfig::generate(&dir, &mut src).unwrap(); let config = Arc::new(config); let engine = Engine::new(config); @@ -248,7 +272,7 @@ Super Wow! #[test] fn check_render_topic() { let dir = tempfile::tempdir().unwrap(); - let mut src: &[u8] = b"Site Name\nAuthor Name\nOne, Two, Three, And More\nadmin\n"; + let mut src: &[u8] = b"Site Name\nAuthor Name\nhttps://special.example.site\nOne, Gallery\nadmin\n"; let config = AppConfig::generate(&dir, &mut src).unwrap(); let config = Arc::new(config); let engine = Engine::new(config); @@ -279,7 +303,7 @@ Super Wow! #[test] fn check_render_empty_topic() { let dir = tempfile::tempdir().unwrap(); - let mut src: &[u8] = b"Site Name\nAuthor Name\nOne, Two, Three, And More\nadmin\n"; + let mut src: &[u8] = b"Site Name\nAuthor Name\nhttps://special.example.site\nOne, Gallery\nadmin\n"; let config = AppConfig::generate(&dir, &mut src).unwrap(); let config = Arc::new(config); let engine = Engine::new(config); @@ -292,7 +316,7 @@ Super Wow! #[test] fn check_render_gallery_topic() { let dir = tempfile::tempdir().unwrap(); - let mut src: &[u8] = b"Site Name\nAuthor Name\nOne, Gallery\nadmin\n"; + let mut src: &[u8] = b"Site Name\nAuthor Name\nhttps://special.example.site\nOne, Gallery\nadmin\n"; let config = AppConfig::generate(&dir, &mut src).unwrap(); let config = Arc::new(config); let engine = Engine::new(config); @@ -313,7 +337,7 @@ Super Wow! #[test] fn check_render_empty_gallery() { let dir = tempfile::tempdir().unwrap(); - let mut src: &[u8] = b"Site Name\nAuthor Name\nOne, Gallery\nadmin\n"; + let mut src: &[u8] = b"Site Name\nAuthor Name\nhttps://special.example.site\nOne, Gallery\nadmin\n"; let config = AppConfig::generate(&dir, &mut src).unwrap(); let config = Arc::new(config); let engine = Engine::new(config); @@ -322,4 +346,44 @@ Super Wow! assert!(page.contains("Coming Soon")); } + + #[test] + fn check_render_rss() { + let dir = tempfile::tempdir().unwrap(); + let mut src: &[u8] = b"Site Name\nAuthor Name\nhttps://special.example.site\nOne, Gallery\nadmin\n"; + let config = AppConfig::generate(&dir, &mut src).unwrap(); + let config = Arc::new(config); + let engine = Engine::new(config); + + let main_post = r#" +### The Main Page + +This is just a main topic page. +"#; + let one_post1 = r#" +### A first post in One + +Super Wow! +"#; + let one_post2 = r#" +### [A second post in One](/one/posts/2) + +Super Wow TWICE! +"#; + + let mut f = File::create(&dir.path().join("site/webroot/main/posts/index.md")).unwrap(); + f.write_all(&main_post.as_bytes()).unwrap(); + + let mut f = File::create(&dir.path().join("site/webroot/one/posts/1.md")).unwrap(); + f.write_all(&one_post1.as_bytes()).unwrap(); + + let mut f = File::create(&dir.path().join("site/webroot/one/posts/2.md")).unwrap(); + f.write_all(&one_post2.as_bytes()).unwrap(); + + let rss = engine.rss().unwrap(); + + assert!(rss.contains("The Main Page")); + assert!(rss.contains("Super Wow!")); + assert!(rss.contains("A second post in One")); + } } diff --git a/src/render/default.rs b/src/render/default.rs index e2473da..4f2f211 100644 --- a/src/render/default.rs +++ b/src/render/default.rs @@ -27,6 +27,7 @@ pub(crate) const TEMPLATE: &str = r#" {%- for topic in site.topics %} {{ topic }} {%- endfor -%} +RSS diff --git a/src/routes.rs b/src/routes.rs index 69e7c66..f4021ae 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -30,7 +30,7 @@ pub(crate) fn router(engine: Arc) -> Router { .data(engine) .get("/static/:fname", static_assets) .get("/favicon.ico", favicon) - .get("/rss", rss_handler) + .get("/rss.xml", rss_handler) .get("/:topic", topic_handler) .get("/:topic/ext/:fname", topic_assets) .get("/:topic/posts/:post", post_handler) @@ -60,10 +60,9 @@ async fn index_handler(req: Request) -> Result> { /// Handler for "/rss" async fn rss_handler(req: Request) -> Result> { let engine = req.data::>().unwrap(); - info!("Handling request to '/rss'"); - // TODO: call a rss function on Engine - - Ok(Response::new(Body::from("TEMPORARY"))) + info!("Handling request to '/rss.xml'"); + let rss = engine.rss()?; + Ok(Response::new(Body::from(rss))) } /// Handler for "/:topic" @@ -147,7 +146,7 @@ mod tests { #[tokio::test] async fn check_all_handlers() { let dir = tempfile::tempdir().unwrap(); - let mut src: &[u8] = b"Site Name\nAuthor Name\nOne, Two, Three, And More\nadmin\n"; + let mut src: &[u8] = b"Site Name\nAuthor Name\nhttps://some.special.site\nOne, Two, Three, And More\nadmin\n"; let app = AppConfig::generate(&dir, &mut src).unwrap(); let engine = Engine::new(Arc::new(app)); let engine = Arc::new(engine); @@ -239,6 +238,12 @@ One Important Test .body(Body::default()) .unwrap(); + let rss_request = Request::builder() + .method("GET") + .uri("http://localhost:9090/rss.xml") + .body(Body::default()) + .unwrap(); + let service = RouterService::new(router).unwrap(); let addr = format!("{}:{}", engine.app.server.bind, engine.app.server.port); let addr: SocketAddr = addr.parse().unwrap(); @@ -266,12 +271,14 @@ One Important Test let topic_asset_resp = client.request(topic_asset_request).await.unwrap(); let static_asset_resp = client.request(static_asset_request).await.unwrap(); let favicon_resp = client.request(favicon_request).await.unwrap(); + let rss_resp = client.request(rss_request).await.unwrap(); assert_eq!(index_resp.status(), StatusCode::OK); assert_eq!(post_resp.status(), StatusCode::OK); assert_eq!(topic_resp.status(), StatusCode::OK); assert_eq!(topic_asset_resp.status(), StatusCode::OK); assert_eq!(static_asset_resp.status(), StatusCode::OK); assert_eq!(favicon_resp.status(), StatusCode::OK); + assert_eq!(rss_resp.status(), StatusCode::OK); let bad_topic_resp = client.request(bad_topic_request).await.unwrap(); let bad_post_resp = client.request(bad_post_request).await.unwrap(); @@ -315,6 +322,12 @@ One Important Test .body(Body::default()) .unwrap(); + let rss_request = Request::builder() + .method("GET") + .uri("http://localhost:8901/rss.xml") + .body(Body::default()) + .unwrap(); + let service = RouterService::new(router).unwrap(); let addr = format!("{}:{}", engine.app.server.bind, engine.app.server.port); let addr: SocketAddr = addr.parse().unwrap(); @@ -340,10 +353,12 @@ One Important Test let post_resp = client.request(post_request).await.unwrap(); let topic_resp = client.request(topic_request).await.unwrap(); let gallery_resp = client.request(gallery_request).await.unwrap(); + let rss_resp = client.request(rss_request).await.unwrap(); assert_eq!(index_resp.status(), StatusCode::OK); assert_eq!(post_resp.status(), StatusCode::OK); assert_eq!(topic_resp.status(), StatusCode::OK); assert_eq!(gallery_resp.status(), StatusCode::OK); + assert_eq!(rss_resp.status(), StatusCode::OK); let _ = tx.send(()); } diff --git a/test_files/site/templates/special.tmpl b/test_files/site/templates/special.tmpl index 5822e47..bdf77e2 100644 --- a/test_files/site/templates/special.tmpl +++ b/test_files/site/templates/special.tmpl @@ -61,6 +61,7 @@ function change_img(dir) { diff --git a/test_files/test-config.toml b/test_files/test-config.toml index 750ba38..a6cf90c 100644 --- a/test_files/test-config.toml +++ b/test_files/test-config.toml @@ -1,6 +1,7 @@ [site] name = "My Awesome Blog!" author = "Neo" +url = "https://something.cool.here" template = "special.tmpl" topics = ["one", "two", "three", "gallery"]