Compare commits

..

No commits in common. "master" and "release/v0.1.0" have entirely different histories.

16 changed files with 87 additions and 180 deletions

2
.gitignore vendored
View file

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

2
Cargo.lock generated
View file

@ -1355,7 +1355,7 @@ checksum = "9f746c4065a8fa3fe23974dd82f15431cc8d40779821001404d10d2e79ca7d79"
[[package]]
name = "pbe"
version = "0.3.1"
version = "0.1.0"
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.3.1"
version = "0.1.0"
edition = "2021"
[dependencies]
@ -22,14 +22,4 @@ syntect = "5.0.0"
tera = "1.19.0"
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 ...
strip = true
lto = true
codegen-units = 1
url = "2.4.0"

View file

@ -31,7 +31,7 @@ pbe /path/to/example-site
The argument provided to `pbe` is the **root site path**. If not specified, the current working directory is used.
Once started up successfully, you can access this site in your browser at http://localhost:8080/.
Once started up successfully, you can access this site in your browser at [http://localhost:8080/].
## Overview
@ -102,7 +102,7 @@ This is the main configuration file which controls how the website is accessed a
| Key | Required? | Description |
|---------------------|-----------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `bind_addr` | Yes | The IP address of the network interface to bind the HTTP server on. Usual values would be something like `0.0.0.0` or `127.0.0.1`. |
| `bind_addr` | Yes | The hostname/IP to bind the HTTP server on. Usual values would be something like `0.0.0.0` or `127.0.0.1`. |
| `bind_port` | Yes | The port to bind the HTTP server on. For example, `8080`. |
| `static_files_path` | Yes | The **relative** path to the directory containing all public web accessible files, e.g. CSS files, images, etc. |
| `templates_path` | Yes | The **relative** path to the directory containing all HTML templates. |
@ -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`.
The URL would end up being `/2023/03/20/lorem-ipsum/` (note the trailing slash).
## 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. |

View file

@ -1,10 +0,0 @@
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,24 +1,9 @@
#!/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/
tar -cvf $package_filename \
README.md \
LICENSE \
example-site \
-C ./artifacts/bin \
pbe \
syntax_to_css
cd ./artifacts/bin
tar -cvf ../../release.tar.gz ./pbe ./syntax_to_css
echo "Packaged release as ${package_filename}"

View file

@ -2,8 +2,6 @@ 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,
@ -96,22 +94,11 @@ 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,16 +2,54 @@ use std::env;
use std::path::{Path, PathBuf};
use actix_files::Files;
use actix_web::{web, App, HttpServer};
use actix_web::web::Redirect;
use actix_web::{web, App, Either, HttpRequest, HttpResponse, HttpServer, Responder};
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,
@ -74,12 +112,6 @@ 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
@ -138,13 +170,12 @@ async fn main() -> anyhow::Result<()> {
HttpServer::new(move || {
App::new() //
.app_data(data.clone())
.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(latest_posts)
.service(latest_posts_by_tag)
.service(posts_archive)
.service(rss_feed)
.service(Files::new("/", &server_config.static_files_path))
.default_service(web::get().to(routes::site_content))
.default_service(web::get().to(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,9 +107,7 @@ impl MarkdownRenderer {
}
pub fn render_to_html(&self, s: &str) -> Result<String, MarkdownError> {
let mut options = pulldown_cmark::Options::all();
options.set(pulldown_cmark::Options::ENABLE_SMART_PUNCTUATION, false);
let parser = Parser::new_ext(s, options);
let parser = Parser::new_ext(s, pulldown_cmark::Options::all());
let events = self.highlight_codeblocks(parser)?;
let mut output = String::new();
pulldown_cmark::html::push_html(&mut output, events);

View file

@ -1,45 +0,0 @@
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,9 +3,6 @@ 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};
use chrono::{Datelike, TimeZone};
@ -65,16 +62,6 @@ 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>,
}
@ -142,13 +129,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}/{}", //
value.date.year(),
value.date.month(),
value.date.day(),
value.slug
);
let url = format!("/{:04}/{:02}/{:02}/{}", value.date.year(), value.date.month(), value.date.day(), value.slug);
let content_html = content_renderer.render(&value.file_path)?;
let tags = value.tags.map_or_else(|| Vec::new(), |x| x.clone());
Ok(Post {
@ -366,37 +347,34 @@ impl SiteService {
Ok(())
}
pub fn serve_latest_post(&self) -> Result<HttpResponse, SiteError> {
pub fn serve_latest_post(&self) -> HttpResponse {
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);
}
let response_body = content.template_renderer.render("latest_post.html", &context)?;
Ok(HttpResponse::Ok().content_type(ContentType::html()).body(response_body))
HttpResponse::Ok().body(content.template_renderer.render("latest_post.html", &context).unwrap())
}
pub fn serve_posts_by_tag(&self, tag: &Tag) -> Result<HttpResponse, SiteError> {
pub fn serve_posts_by_tag(&self, tag: &Tag) -> HttpResponse {
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);
let response_body = content.template_renderer.render("tag.html", &context)?;
Ok(HttpResponse::Ok().content_type(ContentType::html()).body(response_body))
HttpResponse::Ok().body(content.template_renderer.render("tag.html", &context).unwrap())
}
pub fn serve_posts_archive(&self) -> Result<HttpResponse, SiteError> {
pub fn serve_posts_archive(&self) -> HttpResponse {
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);
let response_body = content.template_renderer.render("archive.html", &context)?;
Ok(HttpResponse::Ok().content_type(ContentType::html()).body(response_body))
HttpResponse::Ok().body(content.template_renderer.render("archive.html", &context).unwrap())
}
pub fn serve_rss_feed(&self) -> Result<HttpResponse, SiteError> {
pub fn serve_rss_feed(&self) -> HttpResponse {
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();
@ -419,11 +397,10 @@ impl SiteService {
})
.collect::<Vec<rss::Item>>(),
);
let response_body = channel.to_string();
Ok(HttpResponse::Ok().content_type("application/rss+xml").body(response_body))
HttpResponse::Ok().content_type("application/rss+xml").body(channel.to_string())
}
pub fn serve_content_by_url(&self, req: &HttpRequest) -> Result<Option<Either<HttpResponse, Redirect>>, SiteError> {
pub fn serve_content_by_url(&self, req: &HttpRequest) -> Option<Either<HttpResponse, Redirect>> {
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) {
@ -431,23 +408,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)?;
Ok(Some(Either::Left(HttpResponse::Ok().content_type(ContentType::html()).body(rendered))))
let rendered = content.template_renderer.render("page.html", &context).unwrap();
Some(Either::Left(HttpResponse::Ok().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)?;
Ok(Some(Either::Left(HttpResponse::Ok().content_type(ContentType::html()).body(rendered))))
let rendered = content.template_renderer.render("post.html", &context).unwrap();
Some(Either::Left(HttpResponse::Ok().body(rendered)))
}
Some(Content::Redirect(url)) => {
log::debug!("Found redirect at {}", req.path());
Ok(Some(Either::Right(Redirect::to(url).using_status_code(StatusCode::MOVED_PERMANENTLY))))
Some(Either::Right(Redirect::to(url).permanent()))
}
None => {
log::debug!("No matching content at {}", req.path());
Ok(None)
None
}
}
}

View file

@ -28,9 +28,3 @@ 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();
}
}