Merge pull request 'rss' (#43) from rss into main

Reviewed-on: #43
This commit is contained in:
Anthony J. Martinez 2021-07-11 11:43:28 +00:00
commit d61d408c68
9 changed files with 288 additions and 27 deletions

149
Cargo.lock generated
View File

@ -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.10.1"
version = "0.11.0"
dependencies = [
"anyhow",
"chrono",
"clap",
"glob",
"hyper",
@ -38,6 +39,7 @@ dependencies = [
"pulldown-cmark",
"rand",
"routerify",
"rss",
"serde",
"simplelog",
"tempfile",
@ -46,6 +48,18 @@ dependencies = [
"toml",
]
[[package]]
name = "atom_syndication"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5016bf52ff4f3ed28bf3ec1fed96b53daf4b137d5e6b9f97a8cfae7b57a3a2"
dependencies = [
"chrono",
"derive_builder",
"diligent-date-parser",
"quick-xml",
]
[[package]]
name = "atty"
version = "0.2.14"
@ -155,7 +169,7 @@ dependencies = [
"ansi_term",
"atty",
"bitflags",
"strsim",
"strsim 0.8.0",
"textwrap",
"unicode-width",
"vec_map",
@ -171,6 +185,66 @@ dependencies = [
"lazy_static",
]
[[package]]
name = "darling"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d706e75d87e35569db781a9b5e2416cff1236a47ed380831f959382ccd5f858"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0c960ae2da4de88a91b2d920c2a7233b400bc33cb28453a2987822d8392519b"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim 0.9.3",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b5a2f4ac4969822c62224815d069952656cadc7084fdca9751e6d959189b72"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]]
name = "derive_builder"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2658621297f2cf68762a6f7dc0bb7e1ff2cfd6583daef8ee0fed6f7ec468ec0"
dependencies = [
"darling",
"derive_builder_core",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "derive_builder_core"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2791ea3e372c8495c0bc2033991d76b512cd799d07491fbd6890124db9458bef"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "deunicode"
version = "0.4.3"
@ -186,6 +260,24 @@ dependencies = [
"generic-array",
]
[[package]]
name = "diligent-date-parser"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e37ea528f01b8bfca1f71bcd06a8e6c898bf8fdfbf24dd9dbc7fb49338ed6d84"
dependencies = [
"chrono",
]
[[package]]
name = "encoding_rs"
version = "0.8.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80df024fbc5ac80f87dfef0d9f5209a252f2a497f7f42944cff24d8253cac065"
dependencies = [
"cfg-if",
]
[[package]]
name = "fake-simd"
version = "0.1.2"
@ -364,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",
@ -386,6 +478,12 @@ dependencies = [
"want",
]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "ignore"
version = "0.4.18"
@ -437,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"
@ -664,6 +762,16 @@ dependencies = [
"unicase",
]
[[package]]
name = "quick-xml"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26aab6b48e2590e4a64d1ed808749ba06257882b461d01ca71baeb747074a6dd"
dependencies = [
"encoding_rs",
"memchr",
]
[[package]]
name = "quote"
version = "1.0.9"
@ -761,6 +869,17 @@ dependencies = [
"regex",
]
[[package]]
name = "rss"
version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02e70d6ae72f8a4333af8ce9dce58942020528430eb0d46ee2fcb5e8d4d16377"
dependencies = [
"atom_syndication",
"derive_builder",
"quick-xml",
]
[[package]]
name = "ryu"
version = "1.0.5"
@ -882,6 +1001,12 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a"
[[package]]
name = "strsim"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6446ced80d6c486436db5c078dde11a9f73d42b57fb273121e160b84f63d894c"
[[package]]
name = "syn"
version = "1.0.73"
@ -968,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",
@ -988,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",

View File

@ -1,6 +1,6 @@
[package]
name = "arse"
version = "0.10.1"
version = "0.11.0"
authors = ["Anthony Martinez"]
edition = "2018"
license = "MIT OR Apache-2.0"
@ -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"
@ -22,6 +23,7 @@ log = "0.4"
pulldown-cmark = { version = "0.8", default-features = false, features = ["simd"] }
rand = "0.8"
routerify = "2"
rss = "1"
serde = { version = "1", features = ["derive"] }
simplelog = "0.10.0"
tera = "1"

View File

@ -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
@ -22,6 +23,8 @@ Please enter a name for the site:
Example Site
Please enter the site author's name:
Arthur Writeson
Please enter the base URL for your site:
https://www.example.com
Please enter comma-separated site topics:
one, two, three
2021-05-01T17:34:26.501980660+00:00 [INFO] Creating site filesystem tree
@ -36,6 +39,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 +80,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
@ -87,7 +93,7 @@ The following elements are available within the Tera context for rendering:
- [x] Support custom bind address and port
- [x] Support favicons
- [x] Support a special `gallery` topic
- [ ] Support RSS feeds
- [x] Support RSS feeds
### License

View File

@ -131,6 +131,7 @@ fn csv_to_vec(csv: &str) -> Vec<String> {
pub(crate) struct Site {
pub name: String,
pub author: String,
pub url: String,
pub template: String,
pub topics: Vec<String>,
}
@ -140,10 +141,11 @@ impl Site {
pub(crate) fn new_from_input<R: BufRead>(reader: &mut R) -> Result<Site> {
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());

View File

@ -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,10 +18,11 @@ 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 tera::Tera;
use tera::Context as TemplateContext;
use rss::{Channel, Item};
use tera::{Tera, Context as TemplateContext};
/// Static defaults for the rendering engine.
mod default;
@ -150,7 +152,6 @@ impl Engine {
Self::read_post_to_html(post_path)
}
fn read_post_to_html<P: AsRef<Path>>(path: P) -> Result<String> {
debug!("Rendering Post Markdown to HTML");
trace!("Rendering {} to HTML", &path.as_ref().display());
@ -162,7 +163,64 @@ impl Engine {
Ok(html_output)
}
/// Renders `/rss.xml` for all topics
pub(crate) fn rss(&self) -> Result<String> {
debug!("Rendering RSS Feed");
let site = &self.app.site;
let items = Self::rss_items(&self)?;
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<Vec<Item>> {
debug!("Building RSS Items");
let mut items: Vec<Item> = 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);
}
Ok(items)
}
fn topic_to_item(&self, topic_slug: &str) -> Result<Vec<Item>> {
trace!("Generating RSS Items for topic: {}", &topic_slug);
let mut items: Vec<Item> = 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<Utc> = 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);
}
Ok(items)
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -172,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);
@ -182,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);
@ -214,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);
@ -245,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);
@ -258,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);
@ -279,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);
@ -288,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"));
}
}

View File

@ -27,6 +27,7 @@ pub(crate) const TEMPLATE: &str = r#"
{%- for topic in site.topics %}
<a href="/{{ topic | slugify }}">{{ topic }}</a>
{%- endfor -%}
<a href="/rss.xml">RSS</a>
</nav>
</center>
</header>

View File

@ -30,6 +30,7 @@ pub(crate) fn router(engine: Arc<Engine>) -> Router<Body, Error> {
.data(engine)
.get("/static/:fname", static_assets)
.get("/favicon.ico", favicon)
.get("/rss.xml", rss_handler)
.get("/:topic", topic_handler)
.get("/:topic/ext/:fname", topic_assets)
.get("/:topic/posts/:post", post_handler)
@ -56,6 +57,14 @@ async fn index_handler(req: Request<Body>) -> Result<Response<Body>> {
topic_posts(engine.clone(), "main".to_owned()).await
}
/// Handler for "/rss"
async fn rss_handler(req: Request<Body>) -> Result<Response<Body>> {
let engine = req.data::<Arc<Engine>>().unwrap();
info!("Handling request to '/rss.xml'");
let rss = engine.rss()?;
Ok(Response::new(Body::from(rss)))
}
/// Handler for "/:topic"
async fn topic_handler(req: Request<Body>) -> Result<Response<Body>> {
let engine = req.data::<Arc<Engine>>().unwrap();
@ -137,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);
@ -229,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();
@ -256,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();
@ -305,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();
@ -330,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(());
}

View File

@ -61,6 +61,7 @@ function change_img(dir) {
</main>
<footer>
<p>&#169; {{ site.author }}</p>
<p><a href="/rss.xml">RSS</a></p>
</footer>
</body>
</html>

View File

@ -1,6 +1,7 @@
[site]
name = "My Awesome Blog!"
author = "Neo"
url = "https://something.cool.here"
template = "special.tmpl"
topics = ["one", "two", "three", "gallery"]