Compare commits

...

16 commits

Author SHA1 Message Date
Gered e9df91ff16 move route handler functions to new routes module 2023-07-11 14:09:09 -04:00
Gered 4354157aab version 0.3.1 2023-07-11 13:35:13 -04:00
Gered 07fda97781 include README.md and LICENSE in generated release tarball 2023-07-11 13:05:28 -04:00
Gered e4aa4afcec improvements to generate-release-artifacts.sh
- better filename for the generated archive
- clean before build, always feels like a good idea
- always be sure to include the example site files, instead of doing it
  manually, post-run as i was doing before (and forgot to do for one
  of the releases ... oops ...)
2023-07-11 13:00:08 -04:00
Gered 7fab84f1cb update gitignore 2023-07-11 12:57:43 -04:00
Gered 64b8a678a4 set appropriate "content-type" headers for all responses
i'm slowly learning that actix is far more low-level than what i've
become accustomed to over all the other web stuff i've ever done all
these years.
2023-07-11 12:14:10 -04:00
Gered 746f92f52b allow all routes to also respond to HEAD requests
i've noticed that some services out there seem to issue HEAD requests
to web sites as part of some sort of metadata fetching process about
links contained in content that they are processing (e.g. Mastodon).
currently we were just responding with 404's to these because actix
does not respond to HEAD requests for any route unless you explicitly
set up the request to respond that way (with the sole exception being
the default route handler in actix which is invoked for any type of
request)
2023-07-11 12:12:37 -04:00
Gered 2aba6152bd version 0.3.0 2023-07-10 14:24:02 -04:00
Gered 0d75ca6caf update README.md to reflect trailing slash url changes better
they can still be used of course, but i feel like more people would
default to not using them, so i'd prefer the README to reflect this
by default. meh.
2023-07-10 14:20:01 -04:00
Gered 31fd5d444b update example site with trailing-url-less urls 2023-07-10 14:17:18 -04:00
Gered 0b8423a64f switch from requiring trailing slashes to them being optional 2023-07-10 14:15:13 -04:00
Gered 4cce2d4ad7 version 0.2.1 2023-07-10 13:59:58 -04:00
Gered 54b431810e manually log errors in our SiteError ResponseError implementation
actix's `Logger` middleware would do this too for us, but it also
logs all requests in a "web access log" style which i find personally
extremely annoying and unhelpful
2023-07-09 14:05:12 -04:00
Gered 672408c3eb include and display some build metadata at startup 2023-07-09 13:55:04 -04:00
Gered 3427b954ed add simple error response rendering
we don't really need anything pretty here since 99% of the time, the
error's that would be generated during a request will be something to
do with tera template rendering and thus, are probably only going to be
experienced while fiddling with the site. so we just want to display
the error details and move on.

the vast majority (if not all) of other errors will occuring during
startup and content reloading, and thus we only really need to worry
about them being logged to the console (which they are already)
2023-07-09 13:33:38 -04:00
Gered c7665fdc79 disable "smart punctuation" option of pulldown_cmark
the main thing i dislike with this is how it renders double quotes
as the "fancy" double quotes variation. bleh!
2023-07-06 19:23:35 -04:00
16 changed files with 163 additions and 84 deletions

2
.gitignore vendored
View file

@ -2,4 +2,4 @@
.DS_Store
/.idea
/artifacts
/release.tar.gz
/*.tar.gz

2
Cargo.lock generated
View file

@ -1355,7 +1355,7 @@ checksum = "9f746c4065a8fa3fe23974dd82f15431cc8d40779821001404d10d2e79ca7d79"
[[package]]
name = "pbe"
version = "0.2.0"
version = "0.3.1"
dependencies = [
"actix-files",
"actix-web",

View file

@ -1,7 +1,7 @@
[package]
name = "pbe"
description = "Personal Blog Engine. Gered's umpteenth take on a custom blog."
version = "0.2.0"
version = "0.3.1"
edition = "2021"
[dependencies]
@ -24,6 +24,9 @@ thiserror = "1.0.40"
tokio = "1"
url = "2.4.0"
[build-dependencies]
chrono = "0.4.26"
[profile.release]
# settings that are mainly intended to reduce the size of the release binary while keeping the release binary tuned
# for performance. these alone reduce the binary size by over 50%, which is good enough in my opinion ...

View file

@ -122,7 +122,7 @@ top-level `pages` key. Each page can contain the following:
|------------------|-----------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `file_path` | Yes | The path (relative to `pages_path` found in `server.yml`) to the HTML, Markdown or plain text content for this page. |
| `title` | Yes | The title of the page. This is what will be visible on the website itself. |
| `url` | Yes | The URL this page can be accessed at. This is just the path component of the URL, e.g. `/my-page/`. |
| `url` | Yes | The URL this page can be accessed at. This is just the path component of the URL, e.g. `/my-page`. |
| `alternate_urls` | No | A list of alternate URLs this page can be accessed at. If provided, each of these URLs will result in a redirect response to the main page URL. This is provided mainly as an aide in transitioning from another website which may have served content at different URLs. |
An example file may look like the following:
@ -132,13 +132,13 @@ pages:
- file_path: about.md
title: About This Site
url: /about/
url: /about
- file_path: joke.md
title: Joke
url: /joke/
url: /joke
alternate_urls:
- /trying-to-be-funny/
- /trying-to-be-funny
```
### `posts.yml`
@ -204,7 +204,7 @@ rss:
#### Post URLs
Post URLs are automatically derived from a combination of the `date` and `slug` defined for each post using the format
`/year/month/day/slug/`.
`/year/month/day/slug`.
For example for the post
@ -215,7 +215,7 @@ date: 2023-03-20 18:01
slug: lorem-ipsum
```
The URL would end up being `/2023/03/20/lorem-ipsum/` (note the trailing slash).
The URL would end up being `/2023/03/20/lorem-ipsum`.
## Writing Content
@ -273,7 +273,7 @@ Displays any single page at the individual page's URL. Normally this would displ
### `tag.html`
Displays posts for a given tag, at the tag's URL `/tag/{tag-name}/`. Normally this would display the tag and then all
Displays posts for a given tag, at the tag's URL `/tag/{tag-name}`. Normally this would display the tag and then all
the posts in a list format.
| Key | Type | Description |
@ -283,7 +283,7 @@ the posts in a list format.
### `archive.html`
Displays all posts, at the archive URL `/archive/`. Normally this would be a simple list of all posts showing their
Displays all posts, at the archive URL `/archive`. Normally this would be a simple list of all posts showing their
titles, dates, and tags.
### `latest_post.html`
@ -304,7 +304,7 @@ Contains all information about a single post.
| Field | Type | Description |
|----------------|------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `url` | `string` | The post's URL, e.g. `/2023/06/30/hello-world/` |
| `url` | `string` | The post's URL, e.g. `/2023/06/30/hello-world` |
| `title` | `string` | The post's title, as defined in `posts.yml`. |
| `date` | `int` | The date/time of the post, as defined in `posts.yml`, converted to seconds since Jan 1, 1970. You can use [Tera's `date` filter](https://tera.netlify.app/docs/#date) to display this in a formatted way. |
| `tags` | `string[]` | The post's tags, as defined in `posts.yml`. This may be an empty list if no tags were specified for the post. |
@ -316,7 +316,7 @@ Contains all information about a single page.
| Field | Type | Description |
|----------------|----------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `url` | `string` | The page's URL, as defined in `pages.yml`, e.g. `/my-page/` |
| `url` | `string` | The page's URL, as defined in `pages.yml`, e.g. `/my-page` |
| `title` | `string` | The page's title, as defined in `pages.yml`. |
| `content_html` | `string` | The page's content, rendered as HTML. Most of the time, you'd want to display this in your template using [Tera's `safe` filter](https://tera.netlify.app/docs/#safe) to ensure HTML tags are not escaped. |

10
build.rs Normal file
View file

@ -0,0 +1,10 @@
use std::process::Command;
fn main() {
let git_output = Command::new("git").args(&["rev-parse", "--short", "HEAD"]).output().unwrap();
let git_hash = String::from_utf8(git_output.stdout).unwrap();
println!("cargo:rustc-env=GIT_HASH={git_hash}");
let build_ts = chrono::Utc::now();
println!("cargo:rustc-env=BUILD_TS={build_ts:?}");
}

View file

@ -2,10 +2,10 @@ pages:
- file_path: about.md
title: About This Site
url: /about/
url: /about
- file_path: joke.md
title: Joke
url: /joke/
url: /joke
alternate_urls:
- /trying-to-be-funny/
- /trying-to-be-funny

View file

@ -13,7 +13,7 @@
<a href="{{ post.url }}">{{ post.title }}</a>
<span class="tags">
{%- for tag in post.tags -%}
<span><a href="/tag/{{ tag }}/">{{ tag }}</a></span>
<span><a href="/tag/{{ tag }}">{{ tag }}</a></span>
{%- endfor -%}
</span>
</td>

View file

@ -17,8 +17,8 @@
<h2>My Site</h2>
<nav>
<a href="/">Home</a> |
<a href="/archive/">Archive</a> |
<a href="/about/">About</a>
<a href="/archive">Archive</a> |
<a href="/about">About</a>
</nav>
</header>
<main>

View file

@ -5,7 +5,7 @@
{{ post.date | date(format="%B %e, %Y") }} &mdash;
<span class="tags">
{%- for tag in post.tags -%}
<span><a href="/tag/{{ tag }}/">{{ tag }}</a></span>
<span><a href="/tag/{{ tag }}">{{ tag }}</a></span>
{%- endfor -%}
</span>
</div>

View file

@ -1,9 +1,24 @@
#!/bin/bash
cd "$(dirname "$0")"
package_version=$(cargo pkgid | cut -d# -f2 | cut -d: -f2)
current_target=$(rustc -vV | sed -n 's|host: ||p')
package_filename="pbe-v${package_version}-${current_target}.tar.gz"
rm -f ./artifacts/bin/pbe
rm -f ./artifacts/bin/syntax_to_css
cargo clean
cargo install --path . --root ./artifacts/
cargo install --path ./syntax_to_css/ --root ./artifacts/
cd ./artifacts/bin
tar -cvf ../../release.tar.gz ./pbe ./syntax_to_css
tar -cvf $package_filename \
README.md \
LICENSE \
example-site \
-C ./artifacts/bin \
pbe \
syntax_to_css
echo "Packaged release as ${package_filename}"

View file

@ -2,6 +2,8 @@ use std::fs::File;
use std::io::BufReader;
use std::path::PathBuf;
use crate::util::drop_trailing_slash;
#[derive(Debug, Clone, serde::Deserialize)]
pub struct Server {
pub bind_addr: String,
@ -94,11 +96,22 @@ pub fn load_content(
let mut pages: Pages = load_config(pages_path)?;
for page in pages.pages.iter_mut() {
page.file_path = [&server_config.pages_path, &page.file_path].iter().collect();
drop_trailing_slash(&mut page.url);
if let Some(alternate_urls) = &mut page.alternate_urls {
for alternate_url in alternate_urls.iter_mut() {
drop_trailing_slash(alternate_url);
}
}
}
log::info!("Loading posts config from {:?}", posts_path);
let mut posts: Posts = load_config(posts_path)?;
for post in posts.posts.iter_mut() {
post.file_path = [&server_config.posts_path, &post.file_path].iter().collect();
if let Some(alternate_urls) = &mut post.alternate_urls {
for alternate_url in alternate_urls.iter_mut() {
drop_trailing_slash(alternate_url);
}
}
}
Ok((pages, posts))
}

View file

@ -2,54 +2,16 @@ use std::env;
use std::path::{Path, PathBuf};
use actix_files::Files;
use actix_web::web::Redirect;
use actix_web::{web, App, Either, HttpRequest, HttpResponse, HttpServer, Responder};
use actix_web::{web, App, HttpServer};
use anyhow::Context;
mod config;
mod markdown;
mod routes;
mod site;
mod util;
mod watcher;
fn not_found() -> HttpResponse {
HttpResponse::NotFound().body("not found")
}
#[actix_web::get("/")]
async fn latest_posts(data: web::Data<site::SiteService>) -> impl Responder {
log::debug!("GET / -> latest_posts()");
data.serve_latest_post()
}
#[actix_web::get("/tag/{tag}/")]
async fn latest_posts_by_tag(path: web::Path<(String,)>, data: web::Data<site::SiteService>) -> impl Responder {
let tag = path.into_inner().0;
log::debug!("GET /tag/{0}/ -> latest_posts_by_tag(), tag = {0}", tag);
data.serve_posts_by_tag(&tag)
}
#[actix_web::get("/archive/")]
async fn posts_archive(data: web::Data<site::SiteService>) -> impl Responder {
log::debug!("GET /archive/ -> posts_archive()");
data.serve_posts_archive()
}
#[actix_web::get("/rss/")]
async fn rss_feed(data: web::Data<site::SiteService>) -> impl Responder {
log::debug!("GET /rss/ -> rss_feed()");
data.serve_rss_feed()
}
async fn site_content(req: HttpRequest, data: web::Data<site::SiteService>) -> Either<HttpResponse, Redirect> {
log::debug!("GET {} -> fallback to site_content()", req.path());
if let Some(response) = data.serve_content_by_url(&req) {
response
} else {
Either::Left(not_found())
}
}
fn spawn_watcher(
watch_paths: Vec<PathBuf>,
pages_config_path: PathBuf,
@ -112,6 +74,12 @@ async fn main() -> anyhow::Result<()> {
.map_err(|err| anyhow::anyhow!(err))?;
println!("PBE - Personal Blog Engine - https://github.com/gered/pbe");
println!(
"Build version {0}, git hash {1}, built at {2}",
env!("CARGO_PKG_VERSION"),
env!("GIT_HASH"),
env!("BUILD_TS")
);
// manually handling args because
// 1) i have very simple needs
@ -170,12 +138,13 @@ async fn main() -> anyhow::Result<()> {
HttpServer::new(move || {
App::new() //
.app_data(data.clone())
.service(latest_posts)
.service(latest_posts_by_tag)
.service(posts_archive)
.service(rss_feed)
.wrap(actix_web::middleware::NormalizePath::trim())
.service(routes::latest_posts)
.service(routes::latest_posts_by_tag)
.service(routes::posts_archive)
.service(routes::rss_feed)
.service(Files::new("/", &server_config.static_files_path))
.default_service(web::get().to(site_content))
.default_service(web::get().to(routes::site_content))
})
.bind((server_config.bind_addr.clone(), server_config.bind_port))
.with_context(|| format!("Binding HTTP server on {}:{}", server_config.bind_addr, server_config.bind_port))?

View file

@ -107,7 +107,9 @@ impl MarkdownRenderer {
}
pub fn render_to_html(&self, s: &str) -> Result<String, MarkdownError> {
let parser = Parser::new_ext(s, pulldown_cmark::Options::all());
let mut options = pulldown_cmark::Options::all();
options.set(pulldown_cmark::Options::ENABLE_SMART_PUNCTUATION, false);
let parser = Parser::new_ext(s, options);
let events = self.highlight_codeblocks(parser)?;
let mut output = String::new();
pulldown_cmark::html::push_html(&mut output, events);

45
src/routes.rs Normal file
View file

@ -0,0 +1,45 @@
use actix_web::web::Redirect;
use actix_web::{web, Either, HttpRequest, HttpResponse, Responder};
use crate::site;
fn not_found() -> HttpResponse {
HttpResponse::NotFound().body("not found")
}
#[actix_web::route("/", method = "GET", method = "HEAD")]
pub async fn latest_posts(data: web::Data<site::SiteService>) -> impl Responder {
log::debug!("GET / -> latest_posts()");
data.serve_latest_post()
}
#[actix_web::route("/tag/{tag}", method = "GET", method = "HEAD")]
pub async fn latest_posts_by_tag(path: web::Path<(String,)>, data: web::Data<site::SiteService>) -> impl Responder {
let tag = path.into_inner().0;
log::debug!("GET /tag/{0} -> latest_posts_by_tag(), tag = {0}", tag);
data.serve_posts_by_tag(&tag)
}
#[actix_web::route("/archive", method = "GET", method = "HEAD")]
pub async fn posts_archive(data: web::Data<site::SiteService>) -> impl Responder {
log::debug!("GET /archive -> posts_archive()");
data.serve_posts_archive()
}
#[actix_web::route("/rss", method = "GET", method = "HEAD")]
pub async fn rss_feed(data: web::Data<site::SiteService>) -> impl Responder {
log::debug!("GET /rss -> rss_feed()");
data.serve_rss_feed()
}
pub async fn site_content(
req: HttpRequest,
data: web::Data<site::SiteService>,
) -> Result<Either<HttpResponse, Redirect>, site::SiteError> {
log::debug!("GET {} -> fallback to site_content()", req.path());
if let Some(response) = data.serve_content_by_url(&req)? {
Ok(response)
} else {
Ok(Either::Left(not_found()))
}
}

View file

@ -3,6 +3,8 @@ use std::ops::Deref;
use std::path::PathBuf;
use std::sync::RwLock;
use actix_web::body::BoxBody;
use actix_web::http::header::ContentType;
use actix_web::http::StatusCode;
use actix_web::web::Redirect;
use actix_web::{Either, HttpRequest, HttpResponse};
@ -63,6 +65,16 @@ pub enum SiteError {
TeraError(#[from] tera::Error),
}
impl actix_web::error::ResponseError for SiteError {
fn error_response(&self) -> HttpResponse<BoxBody> {
log::error!("Error response: {:?}", self);
let status_code = self.status_code();
HttpResponse::build(status_code) //
.content_type(ContentType::plaintext())
.body(format!("{status_code}\n\n{:#?}", self))
}
}
pub struct AlternateUrlMappings {
mapping: HashMap<UriPath, UriPath>,
}
@ -131,7 +143,7 @@ pub struct Post {
impl Post {
pub fn try_from(value: config::Post, content_renderer: &ContentRenderer) -> Result<Self, SiteError> {
let url = format!(
"/{:04}/{:02}/{:02}/{}/", //
"/{:04}/{:02}/{:02}/{}", //
value.date.year(),
value.date.month(),
value.date.day(),
@ -354,34 +366,37 @@ impl SiteService {
Ok(())
}
pub fn serve_latest_post(&self) -> HttpResponse {
pub fn serve_latest_post(&self) -> Result<HttpResponse, SiteError> {
let content = self.content.read().expect("SiteContent read lock failed"); // TODO: better error handling
let post = content.get_latest_post();
let mut context = tera::Context::new();
if let Some(post) = post {
context.insert("post", post);
}
HttpResponse::Ok().body(content.template_renderer.render("latest_post.html", &context).unwrap())
let response_body = content.template_renderer.render("latest_post.html", &context)?;
Ok(HttpResponse::Ok().content_type(ContentType::html()).body(response_body))
}
pub fn serve_posts_by_tag(&self, tag: &Tag) -> HttpResponse {
pub fn serve_posts_by_tag(&self, tag: &Tag) -> Result<HttpResponse, SiteError> {
let content = self.content.read().expect("SiteContent read lock failed"); // TODO: better error handling
let posts = content.get_posts_with_tag_ordered_by_date(tag);
let mut context = tera::Context::new();
context.insert("tag", tag);
context.insert("posts", &posts);
HttpResponse::Ok().body(content.template_renderer.render("tag.html", &context).unwrap())
let response_body = content.template_renderer.render("tag.html", &context)?;
Ok(HttpResponse::Ok().content_type(ContentType::html()).body(response_body))
}
pub fn serve_posts_archive(&self) -> HttpResponse {
pub fn serve_posts_archive(&self) -> Result<HttpResponse, SiteError> {
let content = self.content.read().expect("SiteContent read lock failed"); // TODO: better error handling
let posts = content.get_posts_ordered_by_date();
let mut context = tera::Context::new();
context.insert("posts", &posts);
HttpResponse::Ok().body(content.template_renderer.render("archive.html", &context).unwrap())
let response_body = content.template_renderer.render("archive.html", &context)?;
Ok(HttpResponse::Ok().content_type(ContentType::html()).body(response_body))
}
pub fn serve_rss_feed(&self) -> HttpResponse {
pub fn serve_rss_feed(&self) -> Result<HttpResponse, SiteError> {
let content = self.content.read().expect("SiteContent read lock failed"); // TODO: better error handling
let base_url = url::Url::parse(&content.rss.url).unwrap();
let posts = content.get_posts_ordered_by_date();
@ -404,10 +419,11 @@ impl SiteService {
})
.collect::<Vec<rss::Item>>(),
);
HttpResponse::Ok().content_type("application/rss+xml").body(channel.to_string())
let response_body = channel.to_string();
Ok(HttpResponse::Ok().content_type("application/rss+xml").body(response_body))
}
pub fn serve_content_by_url(&self, req: &HttpRequest) -> Option<Either<HttpResponse, Redirect>> {
pub fn serve_content_by_url(&self, req: &HttpRequest) -> Result<Option<Either<HttpResponse, Redirect>>, SiteError> {
let content = self.content.read().expect("SiteContent read lock failed"); // TODO: better error handling
let url = String::from(req.path());
match content.get_content_at(&url) {
@ -415,23 +431,23 @@ impl SiteService {
log::debug!("Found page content at {}", req.path());
let mut context = tera::Context::new();
context.insert("page", page);
let rendered = content.template_renderer.render("page.html", &context).unwrap();
Some(Either::Left(HttpResponse::Ok().body(rendered)))
let rendered = content.template_renderer.render("page.html", &context)?;
Ok(Some(Either::Left(HttpResponse::Ok().content_type(ContentType::html()).body(rendered))))
}
Some(Content::Post(post)) => {
log::debug!("Found post content at {}", req.path());
let mut context = tera::Context::new();
context.insert("post", post);
let rendered = content.template_renderer.render("post.html", &context).unwrap();
Some(Either::Left(HttpResponse::Ok().body(rendered)))
let rendered = content.template_renderer.render("post.html", &context)?;
Ok(Some(Either::Left(HttpResponse::Ok().content_type(ContentType::html()).body(rendered))))
}
Some(Content::Redirect(url)) => {
log::debug!("Found redirect at {}", req.path());
Some(Either::Right(Redirect::to(url).using_status_code(StatusCode::MOVED_PERMANENTLY)))
Ok(Some(Either::Right(Redirect::to(url).using_status_code(StatusCode::MOVED_PERMANENTLY))))
}
None => {
log::debug!("No matching content at {}", req.path());
None
Ok(None)
}
}
}

View file

@ -28,3 +28,9 @@ pub fn serialize_naivedatetime_to_i64<S: serde::Serializer>(
) -> Result<S::Ok, S::Error> {
serializer.serialize_i64(value.timestamp())
}
pub fn drop_trailing_slash(s: &mut String) {
if s.ends_with("/") {
s.pop();
}
}