Restructure project

This commit is contained in:
Pantonshire committed 2022-05-31 18:41:21 +01:00
1 parent 0c158652c9
commit 14726ca7d4
28 files changed
+381 -357

No files matched your search

+35
View File
@@ -0,0 +1,35 @@
use std::{
path::Path,
sync::mpsc,
time::Duration,
};
use miette::{IntoDiagnostic, WrapErr};
use notify::{
DebouncedEvent,
RecommendedWatcher,
RecursiveMode,
Watcher,
watcher,
};
use tracing::info;
pub fn start_watching(
tx: mpsc::Sender<DebouncedEvent>,
watch_path: &Path
) -> miette::Result<RecommendedWatcher>
{
let mut watcher = watcher(tx, Duration::from_secs(2))
.into_diagnostic()
.wrap_err("Failed to create filesystem watcher")?;
// Watch the path in non-recursive mode, so events are not generated for nodes in
// sub-directories.
watcher.watch(watch_path, RecursiveMode::NonRecursive)
.into_diagnostic()
.wrap_err_with(|| format!("Failed to watch directory {}", watch_path.to_string_lossy()))?;
info!(path = %watch_path.to_string_lossy(), "Watching directory");
Ok(watcher)
}
+145
View File
@@ -0,0 +1,145 @@
mod fs_watcher;
mod render;
mod service;
mod template;
use std::{env, fs, path::PathBuf, sync::Arc, thread};
use axum::Server;
use miette::{IntoDiagnostic, Context};
use tracing::info;
use blog::{
codeblock::CodeBlockRenderer,
db::ConcurrentPostsStore,
uuid,
};
use render::Renderer;
#[derive(knuffel::Decode, Clone, Debug)]
pub struct Config {
#[knuffel(child, unwrap(argument))]
bind: String,
#[knuffel(child, unwrap(argument))]
concurrency_limit: usize,
#[knuffel(child, unwrap(argument))]
static_dir: PathBuf,
#[knuffel(child, unwrap(argument))]
favicon_dir: PathBuf,
#[knuffel(child, unwrap(argument))]
robots_path: PathBuf,
#[knuffel(child, unwrap(argument))]
posts_dir: PathBuf,
#[knuffel(child, unwrap(argument))]
post_media_dir: PathBuf,
#[knuffel(child, unwrap(argument))]
namespace_uuid: uuid::Uuid,
#[knuffel(child)]
self_ref: SelfRefConfig,
#[knuffel(child)]
rss: RssConfig,
#[knuffel(child)]
atom: AtomConfig,
}
#[derive(knuffel::Decode, Clone, Debug)]
pub struct SelfRefConfig {
#[knuffel(child, unwrap(argument))]
protocol: String,
#[knuffel(child, unwrap(argument))]
domain: String,
}
#[derive(knuffel::Decode, Clone, Debug)]
pub struct RssConfig {
#[knuffel(child, unwrap(argument))]
num_posts: usize,
#[knuffel(child, unwrap(argument))]
title: String,
#[knuffel(child, unwrap(argument))]
ttl: u32,
}
#[derive(knuffel::Decode, Clone, Debug)]
pub struct AtomConfig {
#[knuffel(child, unwrap(argument))]
num_posts: usize,
#[knuffel(child, unwrap(argument))]
title: String,
}
fn main() -> miette::Result<()> {
tracing_subscriber::fmt::init();
// Load the configuration from the KDL config file specified by the first command-line
// argument.
let config = Arc::new({
let config_path = env::args().nth(1)
.ok_or_else(|| miette::Error::msg("No config file specified"))?;
info!(path = %config_path, "Loading config");
let contents = fs::read_to_string(&config_path)
.into_diagnostic()
.wrap_err_with(|| format!("Failed to read config file {}", config_path))?;
knuffel::parse::<Config>(&config_path, &contents)
.wrap_err_with(|| format!("Failed to parse config file {}", config_path))?
});
// Create the data structure used to store the rendered posts. This uses an `Arc` internally,
// so clones will point to the same underlying data.
let posts_store = ConcurrentPostsStore::new();
let code_renderer = CodeBlockRenderer::new();
// Create the post renderer and the mpsc channel that will be used to communicate with it.
let (renderer, tx) = Renderer::new(
config.clone(),
posts_store.clone(),
code_renderer,
config.posts_dir.clone()
);
// Dropping the watcher stops its thread, so keep it alive until `main` returns.
let _watcher = fs_watcher::start_watching(tx, &config.posts_dir)?;
thread::spawn(move || {
renderer.handle_events();
});
info!("Started renderer thread");
// To run the web server, we need to be in an async context, so create a new Tokio runtime and
// pass control to it.
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.into_diagnostic()
.wrap_err("Failed to create async runtime")?
.block_on(run(config, posts_store))
}
async fn run(
config: Arc<Config>,
posts_store: ConcurrentPostsStore,
) -> miette::Result<()>
{
let bind_address = &config.bind
.parse()
.into_diagnostic()
.wrap_err_with(|| format!("Failed to parse socket address \"{}\"", config.bind))?;
let service = service::site_service(config, posts_store);
info!(address = %bind_address, "Starting server");
Server::try_bind(bind_address)
.into_diagnostic()
.wrap_err_with(|| format!("Failed to bind {}", bind_address))?
.serve(service.into_make_service())
.await
.into_diagnostic()
.wrap_err("Fatal error while running the server")
}
+273
View File
@@ -0,0 +1,273 @@
use std::{
fmt,
fs,
io::{self, Read},
path::PathBuf,
sync::{Arc, mpsc},
};
use chrono::DateTime;
use notify::DebouncedEvent;
use tracing::{info, warn, error};
use blog::{
codeblock::CodeBlockRenderer,
post::{parse as parse_post, ParseError, Post, PostId},
db::ConcurrentPostsStore,
time::unix_epoch,
};
use crate::Config;
pub struct Renderer {
config: Arc<Config>,
posts: ConcurrentPostsStore,
code_renderer: CodeBlockRenderer,
posts_dir_path: PathBuf,
rx: mpsc::Receiver<DebouncedEvent>,
}
impl Renderer {
pub fn new(
config: Arc<Config>,
posts: ConcurrentPostsStore,
code_renderer: CodeBlockRenderer,
posts_dir_path: PathBuf,
) -> (Self, mpsc::Sender<DebouncedEvent>)
{
let (tx, rx) = mpsc::channel();
// Buffer a rescan event here so that it will be the first event received when
// `handle_events` is called. This will cause the `Renderer` to perform an "initial scan"
// of the post files.
tx.send(DebouncedEvent::Rescan).unwrap();
(Self {
config,
posts,
code_renderer,
posts_dir_path,
rx,
}, tx)
}
#[tracing::instrument(skip(self))]
pub fn handle_events(self) {
while let Ok(notify_event) = self.rx.recv() {
let fs_event = match notify_event {
// Convert create & write events for valid post file names to update events.
DebouncedEvent::Create(path) | DebouncedEvent::Write(path) => {
EventTarget::from_path(path)
.map(Event::Update)
},
// Convert remove events for valid post file names.
DebouncedEvent::Remove(path) => {
EventTarget::from_path(path)
.map(Event::Remove)
},
// Convert rename events depending on whether the old / new paths are valid post
// file names.
DebouncedEvent::Rename(old_path, new_path) => {
match (EventTarget::from_path(old_path), EventTarget::from_path(new_path)) {
(Some(old_target), Some(new_target)) => Some(Event::Rename(old_target, new_target)),
(None, Some(new_target)) => Some(Event::Update(new_target)),
(Some(old_target), None) => Some(Event::Remove(old_target)),
(None, None) => None,
}
},
// Convert rescan events, where it is necessary to read the directory's contents.
DebouncedEvent::Rescan => Some(Event::Scan),
// Ignore all other events.
_ => None,
};
if let Some(fs_event) = fs_event {
self.handle_event(&fs_event);
}
}
info!("Filesystem events channel closed, exiting");
}
fn handle_event(&self, event: &Event) {
info!(event = ?event);
match event {
Event::Update(target) => self.update(target),
Event::Rename(old_target, new_target) => self.rename(old_target, new_target),
Event::Remove(target) => self.remove(target),
Event::Scan => self.scan(),
}
}
#[tracing::instrument(skip(self))]
fn update(&self, target: &EventTarget) {
match self.parse_post_from_target(target) {
Ok(post) => {
let mut guard = self.posts.write_blocking();
guard.insert(post);
},
Err(err) => {
err.log();
}
};
}
#[tracing::instrument(skip(self))]
fn rename(&self, old_target: &EventTarget, new_target: &EventTarget) {
let post_res = self.parse_post_from_target(new_target);
let mut guard = self.posts.write_blocking();
guard.remove(&old_target.id);
match post_res {
Ok(post) => {
guard.insert(post);
},
Err(err) => {
err.log();
},
}
}
#[tracing::instrument(skip(self))]
fn remove(&self, target: &EventTarget) {
let mut guard = self.posts.write_blocking();
guard.remove(&target.id);
}
#[tracing::instrument(skip(self))]
fn scan(&self) {
let posts_dir = match fs::read_dir(&self.posts_dir_path) {
Ok(posts_dir) => posts_dir,
Err(err) => {
Error::Io(Box::new(err)).log();
return;
},
};
let mut posts = Vec::new();
for dir_entry in posts_dir {
let dir_entry = match dir_entry {
Ok(dir_entry) => dir_entry,
Err(err) => {
Error::Io(Box::new(err)).log();
continue;
},
};
if let Some(target) = EventTarget::from_path(dir_entry.path()) {
posts.push(match self.parse_post_from_target(&target) {
Ok(post) => post,
Err(err) => {
err.log();
continue;
},
});
}
}
let mut guard = self.posts.write_blocking();
guard.clear();
for post in posts {
guard.insert(post);
}
}
fn parse_post_from_target(&self, target: &EventTarget) -> Result<Post, Error> {
let mut fd = fs::OpenOptions::new()
.read(true)
.open(&target.path)
.map_err(|err| Error::Io(Box::new(err)))?;
let metadata = fd.metadata()
.map_err(|err| Error::Io(Box::new(err)))?;
if !metadata.file_type().is_file() {
return Err(Error::NotAFile);
}
let (created, updated) = metadata.created()
.and_then(|created| metadata.modified()
.map(|modified| (DateTime::from(created), DateTime::from(modified))))
// If created / modified metadata is not available, default to the UNIX epoch.
.unwrap_or_else(|_| {
let epoch = unix_epoch();
(epoch, epoch)
});
let contents = {
let mut buf = String::new();
fd.read_to_string(&mut buf)
.map_err(|err| Error::Io(Box::new(err)))?;
buf
};
drop(fd);
parse_post(
&self.code_renderer,
*self.config.namespace_uuid,
target.id.clone(),
&target.path.to_string_lossy(),
created,
updated,
&contents
).map_err(|err| Error::Parsing(Box::new(err)))
}
}
#[derive(Debug)]
enum Event {
Update(EventTarget),
Rename(EventTarget, EventTarget),
Remove(EventTarget),
Scan,
}
struct EventTarget {
pub path: PathBuf,
pub id: PostId,
}
impl fmt::Debug for EventTarget {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}@{}", self.id, self.path.to_string_lossy())
}
}
impl EventTarget {
pub fn from_path(path: PathBuf) -> Option<Self> {
path.file_name()
.and_then(|file_name| file_name.to_str())
.and_then(PostId::from_file_name)
.map(|id| Self {
path,
id,
})
}
}
pub enum Error {
Io(Box<io::Error>),
NotAFile,
Parsing(Box<ParseError>),
}
impl Error {
fn log(&self) {
match self {
Error::Io(err) => {
error!(error = %err, "IO error while processing event");
},
Error::NotAFile => {
warn!("Event target is not a regular file");
},
Error::Parsing(err) => {
warn!(error = %err, "Parsing error while processing event");
},
}
}
}
+78
View File
@@ -0,0 +1,78 @@
use std::sync::Arc;
use atom_syndication as atom;
use axum::{body::Bytes, extract::Extension};
use blog::{db::ConcurrentPostsStore, time::unix_epoch};
use crate::Config;
use super::response::Atom;
pub async fn handle(
Extension(config): Extension<Arc<Config>>,
Extension(posts): Extension<ConcurrentPostsStore>,
) -> Atom<Bytes> {
let (atom_entries, updated) = {
let guard = posts.read().await;
let atom_entries = guard
.iter_by_created()
.take(config.atom.num_posts)
.map(|post| {
atom::EntryBuilder::default()
.id(format!("urn:uuid:{}", post.uuid()))
.title(post.title().to_owned())
.updated(post.updated())
.links(vec![atom::LinkBuilder::default()
.href(format!(
"{}://{}/articles/{}",
config.self_ref.protocol,
config.self_ref.domain,
post.id()
))
.rel("alternate".to_owned())
.mime_type(Some("text/html".to_owned()))
.build()])
.author(
atom::PersonBuilder::default()
.name(post.author().to_owned())
.build(),
)
.build()
})
.collect::<Vec<atom::Entry>>();
let updated = guard.last_updated().unwrap_or_else(unix_epoch);
(atom_entries, updated)
};
Atom(
atom::FeedBuilder::default()
.id(format!("urn:uuid:{}", *config.namespace_uuid))
.title(config.atom.title.clone())
.updated(updated)
.links(vec![
atom::LinkBuilder::default()
.href(format!(
"{}://{}/atom.xml",
config.self_ref.protocol, config.self_ref.domain
))
.rel("self".to_owned())
.build(),
atom::LinkBuilder::default()
.href(format!(
"{}://{}/articles/",
config.self_ref.protocol, config.self_ref.domain
))
.rel("alternate".to_owned())
.mime_type(Some("text/html".to_owned()))
.build(),
])
.entries(atom_entries)
.build()
.to_string()
.into(),
)
}
+35
View File
@@ -0,0 +1,35 @@
use maud::html;
use crate::template;
use super::response::Html;
pub async fn handle() -> Html {
Html::new()
.with_title_static("Contact")
.with_crawler_permissive()
.with_head(html! {
link href="/static/styles/main.css" rel="stylesheet";
})
.with_body(template::main_page(html! {
section .content_section {
h1 { "Contact" }
p {
"If you want to contact me, you can find me at:"
}
ul {
li {
"Twitter: "
a href="https://twitter.com/pantonshire" { "@pantonshire" }
}
li {
"Mastodon: "
a href="https://tech.lgbt/web/@pantonshire#" { "@pantonshire@tech.lgbt" }
}
li {
"Discord: pantonshire#2076"
}
}
}
}))
}
+69
View File
@@ -0,0 +1,69 @@
use axum::extract::Extension;
use maud::html;
use blog::db::ConcurrentPostsStore;
use crate::template;
use super::response::Html;
pub async fn handle(Extension(posts): Extension<ConcurrentPostsStore>) -> Html {
Html::new()
.with_title_static("Pantonshire")
.with_crawler_permissive()
.with_head(html! {
link href="/static/styles/main.css" rel="stylesheet";
link rel="alternate" type="application/atom+xml" href="/atom.xml";
link rel="alternate" type="application/rss+xml" href="/rss.xml";
})
.with_body(template::main_page(html! {
section .content_section {
p {
"Hi! I'm Tom, a computer science student and hobbyist programmer, and \
definitely not an egg-shaped robot."
}
figure {
img src="/static/images/tombot_450.png"
alt="A drawing by @smolrobots (on Twitter) of me as a robot. It has an \
egg-shaped body and a smiling face, and is doing a little dance next \
to a Raspberry Pi."
width="256";
figcaption .quiet {
"Drawn by "
a href="https://twitter.com/smolrobots" { "@smolrobots" }
}
}
}
section .content_section {
h2 { "Some things I've made" }
ul {
li { a href="https://github.com/pantonshire/goldcrest" { "Goldcrest" } ", a proxy for the Twitter v1 API using gRPC. I made it to address the problem of centralised rate-limit tracking to avoid exceeding to Twitter's quotas." }
li { a href="https://github.com/pantonshire/smolbotbot" { "Smolbotbot" } ", an art archival project for the drawings of " a href="https://twitter.com/smolrobots" { "@smolrobots" } "! A Twitter bot continuously checks for new small robots, and the data it collects is made available at " a href="https://smolbotbot.com" { "smolbotbot.com" } "." }
li { a href="https://github.com/pantonshire/enumscribe" { "Enumscribe" } ", a Rust procedural macro library for generating conversion methods between enum types and strings, with support for " a href="https://serde.rs" { "Serde" } "." }
li { a href="https://github.com/pantonshire/tasque" { "Tasque" } ", a very in-progress cron-like job scheduler that I'm having a lot of fun working on." }
}
}
section .content_section {
h2 { "Articles" }
p { "Some recent ones:" }
ul .articles_list {
@for post in posts.read().await.iter_by_created().rev().take(3) {
li {
h3 { a href={"/articles/" (post.id())} { (post.title()) } }
@if let Some(subtitle) = post.subtitle() {
p .article_list_subtitle { (subtitle) }
}
p .article_list_published_date {
"Published " (post.created().format("%Y/%m/%d"))
}
}
}
}
p {
a href="/articles" { "See the rest" }
}
}
}))
}
+11
View File
@@ -0,0 +1,11 @@
mod atom;
mod contact;
mod index;
mod post;
mod posts_list;
mod response;
mod rss;
mod site;
mod static_content;
pub use site::service as site_service;
+40
View File
@@ -0,0 +1,40 @@
use axum::extract::{Extension, Path};
use maud::html;
use blog::db::ConcurrentPostsStore;
use crate::template;
use super::response::{Error, Html};
pub async fn handle(
Path(post_id): Path<String>,
Extension(posts): Extension<ConcurrentPostsStore>
) -> Result<Html, Error>
{
let post = posts.get(&post_id)
.await
.ok_or(Error::PostNotFound)?;
Ok(Html::new()
.with_crawler_permissive()
.with_title_owned(post.title().to_owned())
.with_head(html! {
link href="/static/styles/main.css" rel="stylesheet";
link href="/static/styles/code.css" rel="stylesheet";
link rel="alternate" type="application/atom+xml" href="/atom.xml";
link rel="alternate" type="application/rss+xml" href="/rss.xml";
})
.with_body(template::main_page(html! {
section .article_header {
h1 .article_title { (post.title()) }
@if let Some(subtitle) = post.subtitle() {
p .article_subtitle { (subtitle) }
}
p .article_published_date { "Published " (post.created().format("%Y/%m/%d")) }
}
article .article_content {
(post.html())
}
})))
}
+40
View File
@@ -0,0 +1,40 @@
use axum::extract::Extension;
use maud::html;
use blog::db::ConcurrentPostsStore;
use crate::template;
use super::response::Html;
pub async fn handle(Extension(posts): Extension<ConcurrentPostsStore>) -> Html {
Html::new()
.with_title_static("Articles")
.with_crawler_permissive()
.with_head(html! {
link href="/static/styles/main.css" rel="stylesheet";
link rel="alternate" type="application/atom+xml" href="/atom.xml";
link rel="alternate" type="application/rss+xml" href="/rss.xml";
})
.with_body(template::main_page(html! {
section .content_section {
h1 { "Articles" }
p {
"A collection of words I have written, against my better judgement."
}
ul .articles_list {
@for post in posts.read().await.iter_by_created().rev() {
li {
h3 { a href={"/articles/" (post.id())} { (post.title()) } }
@if let Some(subtitle) = post.subtitle() {
p .article_list_subtitle { (subtitle) }
}
p .article_list_published_date {
"Published " (post.created().format("%Y/%m/%d"))
}
}
}
}
}
}))
}
+298
View File
@@ -0,0 +1,298 @@
use std::{
borrow::Cow,
fmt::{self, Write},
};
use axum::{
body::{Bytes, Full},
http::{header::{self, HeaderValue}, StatusCode},
response::{IntoResponse, Response},
};
use maud::{html, Markup, Render, Escaper, DOCTYPE};
#[derive(Debug)]
pub enum Error {
Internal,
PostNotFound,
StaticResourceNotFound,
RouteNotFound,
}
impl Error {
fn status_code(&self) -> StatusCode {
match self {
Error::Internal => StatusCode::INTERNAL_SERVER_ERROR,
Error::PostNotFound => StatusCode::NOT_FOUND,
Error::StaticResourceNotFound => StatusCode::NOT_FOUND,
Error::RouteNotFound => StatusCode::NOT_FOUND,
}
}
}
impl IntoResponse for Error {
fn into_response(self) -> Response {
let status_code = self.status_code();
// Create a string buffer containing the full error text, e.g. "404 Not Found".
let status_text = {
let status_code_str = status_code.as_str();
let reason = status_code.canonical_reason();
// Allocate a buffer with enough capacity to store the full error text.
let mut buf = String::with_capacity(
status_code_str.len() + reason.map(|reason| reason.len() + 1).unwrap_or(0));
// Push the numerical code string first, then a space, then the error reason string.
buf.push_str(status_code_str);
if let Some(reason) = reason {
buf.push(' ');
buf.push_str(reason);
}
buf
};
Html::new()
.with_status(status_code)
.with_body(html! {
p { (status_text) }
})
.with_title_owned(status_text)
.into_response()
}
}
pub struct Html {
status: StatusCode,
title: Cow<'static, str>,
head: Option<Markup>,
body: Option<Markup>,
crawler_hints: CrawlerHints,
}
impl Html {
pub fn new() -> Self {
Self {
status: StatusCode::OK,
title: Cow::Borrowed("untitled"),
head: None,
body: None,
crawler_hints: CrawlerHints::restrictive(),
}
}
pub fn with_status(self, status: StatusCode) -> Self {
Self { status, ..self }
}
pub fn with_title(self, title: Cow<'static, str>) -> Self {
Self { title, ..self }
}
pub fn with_title_static(self, title: &'static str) -> Self {
self.with_title(Cow::Borrowed(title))
}
pub fn with_title_owned(self, title: String) -> Self {
self.with_title(Cow::Owned(title))
}
pub fn with_head(self, head: Markup) -> Self {
Self { head: Some(head), ..self }
}
pub fn with_body(self, body: Markup) -> Self {
Self { body: Some(body), ..self }
}
pub fn with_crawler_hints(self, crawler_hints: CrawlerHints) -> Self {
Self { crawler_hints, ..self }
}
pub fn with_crawler_restrictive(self) -> Self {
self.with_crawler_hints(CrawlerHints::restrictive())
}
pub fn with_crawler_permissive(self) -> Self {
self.with_crawler_hints(CrawlerHints::permissive())
}
}
impl Default for Html {
fn default() -> Self {
Self::new()
}
}
impl IntoResponse for Html {
fn into_response(self) -> Response {
let html_doc = html! {
(DOCTYPE)
html {
head {
meta charset="utf-8";
meta name="robots" content=(self.crawler_hints);
meta name="viewport" content="width=device-width, initial-scale=1";
title { (self.title) }
link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png";
link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png";
link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png";
link rel="manifest" href="/site.webmanifest";
@if let Some(head) = self.head {
(head)
}
}
body {
@if let Some(body) = self.body {
(body)
}
}
}
};
(self.status, axum::response::Html(html_doc.into_string()))
.into_response()
}
}
#[derive(Clone, Copy, Debug)]
pub struct CrawlerHints {
index: bool,
follow: bool,
archive: bool,
snippet: bool,
image_index: bool,
}
impl CrawlerHints {
pub const fn restrictive() -> Self {
Self {
index: false,
follow: false,
archive: false,
snippet: false,
image_index: false,
}
}
pub const fn permissive() -> Self {
Self {
index: true,
follow: true,
archive: true,
snippet: true,
image_index: true,
}
}
pub const fn with_index(self, index: bool) -> Self {
Self { index, ..self }
}
pub const fn with_follow(self, follow: bool) -> Self {
Self { follow, ..self }
}
pub const fn with_archive(self, archive: bool) -> Self {
Self { archive, ..self }
}
pub const fn with_snippet(self, snippet: bool) -> Self {
Self { snippet, ..self }
}
pub const fn with_image_index(self, image_index: bool) -> Self {
Self { image_index, ..self }
}
fn index_str(self) -> &'static str {
if self.index {
"index"
} else {
"noindex"
}
}
fn follow_str(self) -> &'static str {
if self.follow {
"follow"
} else {
"nofollow"
}
}
fn archive_strs(self) -> Option<[&'static str; 2]> {
if self.archive {
None
} else {
Some(["noarchive", "nocache"])
}
}
fn snippet_str(self) -> Option<&'static str> {
if self.snippet {
None
} else {
Some("nosnippet")
}
}
fn image_index_str(self) -> Option<&'static str> {
if self.image_index {
None
} else {
Some("noimageindex")
}
}
fn write_meta_list_to<W: Write>(self, mut buf: W) -> fmt::Result {
write!(buf, "{},{}", self.index_str(), self.follow_str())?;
if let Some([archive_str, cache_str]) = self.archive_strs() {
write!(buf, ",{},{}", archive_str, cache_str)?;
}
if let Some(snippet_str) = self.snippet_str() {
write!(buf, ",{}", snippet_str)?;
}
if let Some(image_index_str) = self.image_index_str() {
write!(buf, ",{}", image_index_str)?;
}
Ok(())
}
}
impl Render for CrawlerHints {
fn render_to(&self, buf: &mut String) {
let escaper = Escaper::new(buf);
let _result = self.write_meta_list_to(escaper);
}
}
pub struct Rss<T>(pub T);
impl<T: Into<Full<Bytes>>> IntoResponse for Rss<T> {
fn into_response(self) -> Response {
let headers = [
(header::CONTENT_TYPE, HeaderValue::from_static("application/rss+xml")),
];
(headers, self.0.into())
.into_response()
}
}
pub struct Atom<T>(pub T);
impl<T: Into<Full<Bytes>>> IntoResponse for Atom<T> {
fn into_response(self) -> Response {
let headers = [
(header::CONTENT_TYPE, HeaderValue::from_static("application/atom+xml")),
];
(headers, self.0.into())
.into_response()
}
}
+62
View File
@@ -0,0 +1,62 @@
use std::sync::Arc;
use axum::{
body::Bytes,
extract::Extension,
};
use blog::{
db::ConcurrentPostsStore,
time::unix_epoch,
};
use crate::Config;
use super::response::Rss;
pub async fn handle(
Extension(config): Extension<Arc<Config>>,
Extension(posts): Extension<ConcurrentPostsStore>,
) -> Rss<Bytes> {
let (rss_items, updated) = {
let guard = posts.read().await;
let rss_items = guard.iter_by_created()
.take(config.rss.num_posts)
.map(|post| {
rss::ItemBuilder::default()
.title(Some(post.title().to_owned()))
.guid(Some(rss::GuidBuilder::default()
.value(post.uuid().to_string())
.permalink(false)
.build()))
.link(Some(format!(
"{}://{}/articles/{}",
config.self_ref.protocol,
config.self_ref.domain,
post.id()
)))
.pub_date(Some(post.created().to_rfc2822()))
.build()
})
.collect::<Vec<rss::Item>>();
let updated = guard.last_updated()
.unwrap_or_else(unix_epoch);
(rss_items, updated)
};
Rss(rss::ChannelBuilder::default()
.title(config.rss.title.clone())
.link(format!(
"{}://{}",
config.self_ref.protocol, config.self_ref.domain
))
.ttl(Some(config.rss.ttl.to_string()))
.last_build_date(Some(updated.to_rfc2822()))
.items(rss_items)
.build()
.to_string()
.into())
}
+61
View File
@@ -0,0 +1,61 @@
use std::sync::Arc;
use axum::{
handler::Handler,
http::Uri,
extract::Extension,
Router,
routing::get,
};
use tower::limit::ConcurrencyLimitLayer;
use tower_http::trace::TraceLayer;
use tracing::info;
use blog::db::ConcurrentPostsStore;
use crate::Config;
use super::{
atom,
contact,
index,
post,
posts_list,
response::Error,
rss,
static_content,
};
pub fn service(
config: Arc<Config>,
posts_store: ConcurrentPostsStore,
) -> Router
{
Router::new()
.route("/", get(index::handle))
.route("/contact", get(contact::handle))
.route("/articles", get(posts_list::handle))
.route("/rss.xml", get(rss::handle))
.route("/atom.xml", get(atom::handle))
.route("/articles/:post_id", get(post::handle))
.route("/robots.txt", static_content::file_service(&config.robots_path, None))
.route("/favicon.ico", static_content::file_service(&config.favicon_dir.join("favicon.ico"), None))
.route("/favicon-16x16.png", static_content::file_service(&config.favicon_dir.join("favicon-16x16.png"), None))
.route("/favicon-32x32.png", static_content::file_service(&config.favicon_dir.join("favicon-32x32.png"), None))
.route("/apple-touch-icon.png", static_content::file_service(&config.favicon_dir.join("apple-touch-icon.png"), None))
.route("/android-chrome-192x192.png", static_content::file_service(&config.favicon_dir.join("android-chrome-192x192.png"), None))
.route("/android-chrome-512x512.png", static_content::file_service(&config.favicon_dir.join("android-chrome-512x512.png"), None))
.route("/site.webmanifest", static_content::file_service(&config.favicon_dir.join("site.webmanifest"), None))
.nest("/static", static_content::dir_service(&config.static_dir))
.nest("/article_media", static_content::dir_service(&config.post_media_dir))
.fallback(handle_fallback.into_service())
.layer(ConcurrencyLimitLayer::new(config.concurrency_limit))
.layer(TraceLayer::new_for_http())
.layer(Extension(config))
.layer(Extension(posts_store))
}
pub async fn handle_fallback(uri: Uri) -> Error {
info!(path = %uri.path(), "Requested resource not found");
Error::RouteNotFound
}
@@ -0,0 +1,51 @@
use std::{
convert::Infallible,
io,
path::Path,
};
use axum::{
body::Body,
handler::Handler,
http::Uri,
routing::{get_service, MethodRouter},
};
use libshire::convert::Empty;
use mime::Mime;
use tower::ServiceExt;
use tower_http::services::{ServeDir, ServeFile};
use tracing::{info, error};
use super::response::Error;
pub fn file_service(file_path: &Path, mime: Option<&Mime>) -> MethodRouter<Body, Infallible> {
let serve_file = match mime {
Some(mime) => ServeFile::new_with_mime(file_path, mime),
None => ServeFile::new(file_path),
};
get_service(serve_file)
.handle_error(handle_error)
}
pub fn dir_service(dir_path: &Path) -> MethodRouter<Body, Infallible> {
let fallback_service = handle_fallback
.into_service()
.map_err(Empty::elim::<io::Error>);
let serve_dir = ServeDir::new(dir_path)
.fallback(fallback_service);
get_service(serve_dir)
.handle_error(handle_error)
}
pub async fn handle_fallback(uri: Uri) -> Error {
info!(path = %uri.path(), "Requested static file not found");
Error::StaticResourceNotFound
}
pub async fn handle_error(uri: Uri, err: io::Error) -> Error {
error!(path = %uri.path(), err = %err, "IO error");
Error::Internal
}
+48
View File
@@ -0,0 +1,48 @@
use maud::{html, Markup};
pub fn main_page(content: Markup) -> Markup {
html! {
header #page_header {
nav #page_nav {
#title_box {
a href="/" { "Pantonshire" }
}
#right_nav_box {
ul {
li { a href="/articles" { "Articles" } }
li { a href="/atom.xml" { "Atom" } }
li { a href="/rss.xml" { "RSS" } }
li { a href="https://github.com/pantonshire" { "GitHub" } }
li { a href="/contact" { "Contact" } }
}
}
}
}
main #page_main {
#content {
(content)
}
}
footer #page_footer {
#page_footer_content {
ul {
li {
"\"What is a footer? A miserable little pile of secrets.\""
}
li {
a href="https://github.com/pantonshire/blog_server" {
"Source code for this site"
}
}
li {
a rel="me" href="https://tech.lgbt/@pantonshire" {
"Backlink for Mastodon"
}
}
}
}
}
}
}
+31
View File
@@ -0,0 +1,31 @@
use std::{env, process};
use syntect::{
highlighting::ThemeSet,
html::css_for_theme_with_class_style,
};
use blog::codeblock::CLASS_STYLE;
fn main() {
let theme_set = ThemeSet::load_defaults();
let theme_name = env::args().nth(1).unwrap_or_else(|| {
eprintln!("No theme specified");
eprint_available_themes(&theme_set);
process::exit(1)
});
let theme = theme_set.themes.get(&theme_name).unwrap_or_else(|| {
eprintln!("Theme not found: {}", theme_name);
eprint_available_themes(&theme_set);
process::exit(1)
});
let css = css_for_theme_with_class_style(theme, CLASS_STYLE);
println!("{}", css);
}
fn eprint_available_themes(theme_set: &ThemeSet) {
eprintln!("Available themes:");
for key in theme_set.themes.keys() {
eprintln!(" {}", key);
}
}
+79
View File
@@ -0,0 +1,79 @@
use maud::{html, Markup, PreEscaped};
use syntect::html::{ClassedHTMLGenerator, ClassStyle};
use syntect::parsing::SyntaxSet;
use syntect::util::LinesWithEndings;
pub const CLASS_STYLE: ClassStyle = ClassStyle::SpacedPrefixed { prefix: "cb_" };
pub struct CodeBlockRenderer {
syntax_set: SyntaxSet,
}
impl CodeBlockRenderer {
pub fn new() -> Self {
// Load Syntect's default syntax set from Sublime syntax definitions embedded in the
// binary.
let default_syntax_set = SyntaxSet::load_defaults_newlines();
Self::new_with_syntax_set(default_syntax_set)
}
pub fn new_with_syntax_set(syntax_set: SyntaxSet) -> Self {
Self {
syntax_set,
}
}
pub fn render(&self, lang: &str, source: &str) -> Markup {
const CONTEXT_DELIM: &str = "@@";
// Grab the optional context information between @@s from the first line of the code block.
let (context, source) = source.split_once('\n')
.and_then(|(context, source)| context
.trim()
.strip_prefix(CONTEXT_DELIM)
.and_then(|context| context.strip_suffix(CONTEXT_DELIM))
.map(|context| (Some(context.trim()), source)))
.unwrap_or((None, source));
// Search the syntax set for the syntax definition for the language specified for the code
// block (after the triple backtick), and default to plaintext if no syntax definition is
// found.
let syntax = self.syntax_set
.find_syntax_by_token(lang)
.unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
let mut html_gen = ClassedHTMLGenerator::new_with_class_style(
syntax,
&self.syntax_set,
CLASS_STYLE
);
for line in LinesWithEndings::from(source) {
html_gen.parse_html_for_line_which_includes_newline(line);
}
let html_out = html_gen.finalize();
html! {
.codeblock {
@if context.is_some() || !lang.is_empty() {
.codeblock_banner {
span .codeblock_language { (lang) }
span .codeblock_context { (context.unwrap_or("")) }
}
}
pre .codeblock_code {
code {
(PreEscaped(html_out))
}
}
}
}
}
}
impl Default for CodeBlockRenderer {
fn default() -> Self {
Self::new()
}
}
+163
View File
@@ -0,0 +1,163 @@
use std::{
collections::{BTreeSet, hash_map, HashMap, HashSet},
iter::FusedIterator,
sync::Arc,
};
use chrono::{DateTime, Utc};
use libshire::strings::ShString22;
use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard};
use crate::post::{Post, PostId};
#[derive(Clone)]
pub struct ConcurrentPostsStore {
inner: Arc<RwLock<PostsStore>>,
}
impl ConcurrentPostsStore {
pub fn new() -> Self {
Self { inner: Arc::new(RwLock::new(PostsStore::new())) }
}
pub async fn read(&self) -> RwLockReadGuard<'_, PostsStore> {
self.inner.read().await
}
pub fn write_blocking(&self) -> RwLockWriteGuard<'_, PostsStore> {
self.inner.blocking_write()
}
pub async fn get(&self, id: &str) -> Option<Arc<Post>> {
self.read().await.get(id).cloned()
}
}
impl Default for ConcurrentPostsStore {
fn default() -> Self {
Self::new()
}
}
pub struct PostsStore {
posts: HashMap<PostId, Arc<Post>>,
created_ix: BTreeSet<CreatedIxEntry>,
tags_ix: HashMap<ShString22, HashSet<PostId>>,
}
// TODO: shrink the various collections on removal to deallocate unneeded space
impl PostsStore {
pub fn new() -> Self {
Self {
posts: HashMap::new(),
created_ix: BTreeSet::new(),
tags_ix: HashMap::new(),
}
}
pub fn get(&self, id: &str) -> Option<&Arc<Post>> {
self.posts.get(id)
}
pub fn insert(&mut self, post: Post) -> Option<Arc<Post>> {
let old_post = self.remove(post.id());
// Insert the post into each of the tag indexes.
for tag in post.tags() {
// First, get the existing `HashSet` for the tag, or create a new one if one does not
// already exist. Then, insert the post's ID into the `HashSet`.
match self.tags_ix.entry(tag.clone()) {
hash_map::Entry::Occupied(entry) => entry.into_mut(),
hash_map::Entry::Vacant(entry) => entry.insert(HashSet::new()),
}.insert(post.id().clone());
}
// Insert the post into the correct position of the created BTree index.
self.created_ix.insert(CreatedIxEntry::new(&post));
// Wrap the post with an atomic reference counter and insert it into the main posts
// `HashMap`.
self.posts.insert(post.id().clone(), Arc::new(post));
old_post
}
pub fn remove(&mut self, id: &str) -> Option<Arc<Post>> {
match self.posts.remove(id) {
Some(post) => {
// Remove the post's entry in the created index.
self.created_ix
.remove(&CreatedIxEntry::new(&post));
// Remove every occurence of the post from the tags index.
for tag in post.tags() {
if let Some(tag_ix) = self.tags_ix.get_mut(tag) {
tag_ix.remove(id);
}
}
Some(post)
},
None => None,
}
}
pub fn clear(&mut self) {
self.tags_ix.clear();
self.created_ix.clear();
self.posts.clear();
}
pub fn last_updated(&self) -> Option<DateTime<Utc>> {
self.iter().map(|post| post.updated()).max()
}
pub fn iter(&self)
-> impl '_
+ Iterator<Item = &Arc<Post>>
+ ExactSizeIterator
+ FusedIterator
+ Clone
{
self.posts.values()
}
pub fn iter_by_created(&self)
-> impl '_
+ Iterator<Item = &Arc<Post>>
+ DoubleEndedIterator
+ ExactSizeIterator
+ FusedIterator
+ Clone
{
// For each entry of the created index, look up the corresponding post in the posts map and
// return the post. Every entry in the created index should contain the ID of a post in the
// posts map, so the `expect` should never fail.
self.created_ix
.iter()
.map(|entry| self.get(&entry.id)
.expect("invalid entry in `created_ix` pointing to a post that does not exist"))
}
}
impl Default for PostsStore {
fn default() -> Self {
Self::new()
}
}
#[derive(PartialEq, Eq, PartialOrd, Ord)]
struct CreatedIxEntry {
created: DateTime<Utc>,
id: PostId,
}
impl CreatedIxEntry {
fn new(post: &Post) -> Self {
Self {
created: post.created(),
id: post.id().clone(),
}
}
}
+5
View File
@@ -0,0 +1,5 @@
pub mod codeblock;
pub mod post;
pub mod db;
pub mod time;
pub mod uuid;
+88
View File
@@ -0,0 +1,88 @@
use std::{borrow, fmt, ops};
use libshire::strings::ShString22;
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct PostId(ShString22);
impl PostId {
#[inline]
#[must_use]
pub fn from_file_name(file_name: &str) -> Option<Self> {
fn is_invalid_char(c: char) -> bool {
c == '/' || c == '\\' || c == '.'
}
let prefix = file_name
.strip_suffix(super::POST_FILE_EXTENSION)?;
if prefix.contains(is_invalid_char) {
return None;
}
Some(Self(ShString22::new_from_str(prefix)))
}
#[inline]
#[must_use]
pub fn into_inner(self) -> ShString22 {
self.0
}
#[inline]
#[must_use]
pub fn as_inner(&self) -> &ShString22 {
&self.0
}
}
impl ops::Deref for PostId {
type Target = str;
#[inline]
fn deref(&self) -> &Self::Target {
&*self.0
}
}
impl ops::DerefMut for PostId {
#[inline]
fn deref_mut(&mut self) -> &mut Self::Target {
&mut *self.0
}
}
impl AsRef<str> for PostId {
#[inline]
fn as_ref(&self) -> &str {
self
}
}
impl AsMut<str> for PostId {
#[inline]
fn as_mut(&mut self) -> &mut str {
self
}
}
impl borrow::Borrow<str> for PostId {
#[inline]
fn borrow(&self) -> &str {
self
}
}
impl borrow::BorrowMut<str> for PostId {
#[inline]
fn borrow_mut(&mut self) -> &mut str {
self
}
}
impl fmt::Display for PostId {
#[inline]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&**self, f)
}
}
+61
View File
@@ -0,0 +1,61 @@
mod id;
mod parse;
use chrono::{DateTime, Utc};
use libshire::{strings::ShString22, uuid::Uuid};
use maud::{Markup, PreEscaped};
pub use id::PostId;
pub use parse::{parse, ParseError};
const POST_FILE_EXTENSION: &str = ".kdl.md";
pub struct Post {
uuid: Uuid,
id: PostId,
title: String,
subtitle: Option<String>,
author: ShString22,
html: Markup,
tags: Vec<ShString22>,
created: DateTime<Utc>,
updated: DateTime<Utc>,
}
impl Post {
pub fn uuid(&self) -> Uuid {
self.uuid
}
pub fn id(&self) -> &PostId {
&self.id
}
pub fn title(&self) -> &str {
&self.title
}
pub fn subtitle(&self) -> Option<&str> {
self.subtitle.as_deref()
}
pub fn author(&self) -> &str {
&self.author
}
pub fn html(&self) -> PreEscaped<&str> {
PreEscaped(&self.html.0)
}
pub fn tags(&self) -> &[ShString22] {
&self.tags
}
pub fn created(&self) -> DateTime<Utc> {
self.created
}
pub fn updated(&self) -> DateTime<Utc> {
self.updated
}
}
+215
View File
@@ -0,0 +1,215 @@
use std::{error, fmt};
use chrono::{DateTime, Utc};
use libshire::{strings::ShString22, uuid::{Uuid, UuidV5Error}};
use maud::{html, PreEscaped};
use crate::codeblock::CodeBlockRenderer;
use super::{id::PostId, Post};
pub fn parse(
code_renderer: &CodeBlockRenderer,
namespace: Uuid,
post_id: PostId,
file_name: &str,
created: DateTime<Utc>,
updated: DateTime<Utc>,
source: &str,
) -> Result<Post, ParseError>
{
MdPost::parse(file_name, source)
.and_then(|post| render_mdpost(
code_renderer,
namespace,
post_id,
created,
updated,
post
))
}
fn render_mdpost(
code_renderer: &CodeBlockRenderer,
namespace: Uuid,
id: PostId,
created: DateTime<Utc>,
updated: DateTime<Utc>,
mdpost: MdPost,
) -> Result<Post, ParseError>
{
use pulldown_cmark::{Options, Parser, html::push_html};
const PARSER_OPTIONS: Options = Options::ENABLE_TABLES
.union(Options::ENABLE_FOOTNOTES)
.union(Options::ENABLE_STRIKETHROUGH);
let uuid = Uuid::new_v5(namespace, &*id)
.map_err(|err| match err {
UuidV5Error::NameTooLong(len) => ParseError::IdTooLong(len),
})?;
let mut parser = PostMdParser::new(
Parser::new_ext(&mdpost.markdown, PARSER_OPTIONS),
code_renderer
);
let mut html_buf = String::new();
push_html(&mut html_buf, parser.by_ref());
Ok(Post {
uuid,
id,
title: mdpost.title,
subtitle: mdpost.subtitle,
author: mdpost.author,
html: PreEscaped(html_buf),
tags: mdpost.tags,
created,
updated,
})
}
/// Iterator struct which wraps another event iterator in order to render code blocks, collect the links
/// encountered and generate a summary of the text content.
struct PostMdParser<'p, I> {
iter: I,
code_renderer: &'p CodeBlockRenderer,
links: Vec<String>,
summary: String,
}
impl<'p, I> PostMdParser<'p, I> {
fn new(iter: I, code_renderer: &'p CodeBlockRenderer) -> Self {
Self {
iter,
code_renderer,
links: Vec::new(),
summary: String::new(),
}
}
}
impl<'e, 'p, I> Iterator for PostMdParser<'p, I> where I: Iterator<Item = pulldown_cmark::Event<'e>> {
type Item = pulldown_cmark::Event<'e>;
fn next(&mut self) -> Option<Self::Item> {
use pulldown_cmark::{CodeBlockKind, CowStr, Event, LinkType, Tag};
self.iter.next().map(|event| match event {
// When we reach a code block, we want to collect the text content until the code block finishes
// and have the `CodeBlockRenderer` render it
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang))) => {
let mut code_buf = String::new();
for event in self.iter.by_ref() {
match event {
// The code block has finished, so break out of the loop
Event::End(Tag::CodeBlock(_)) => break,
// All text events until the end of the code block should be considered as code, so
// add the text to the `code_buf` to be rendered later
Event::Text(text) => code_buf.push_str(&text),
// Ignore all other events
_ => (),
}
}
let highlighted = self.code_renderer.render(&lang, &code_buf);
Event::Html(CowStr::Boxed(highlighted.into_string().into_boxed_str()))
},
Event::Code(code) => {
Event::Html(CowStr::Boxed(html! {
code .inline_code { (code) }
}.into_string().into_boxed_str()))
},
event => {
match &event {
Event::Start(Tag::Link(LinkType::Inline | LinkType::Autolink, destination, _title)) => {
self.links.push(destination.clone().into_string());
},
//TODO: better way of generating a summary
Event::Text(text) => {
if self.summary.is_empty() {
self.summary = text.clone().into_string();
}
},
_ => (),
}
event
},
})
}
}
#[derive(knuffel::Decode)]
struct HeaderNode {
#[knuffel(child, unwrap(argument))]
title: String,
#[knuffel(child, unwrap(argument))]
subtitle: Option<String>,
#[knuffel(child, unwrap(argument))]
author: String,
#[knuffel(children(name="tag"))]
tags: Vec<TagNode>,
}
#[derive(knuffel::Decode)]
struct TagNode {
#[knuffel(argument)]
tag: String,
}
#[derive(Debug)]
struct MdPost {
markdown: String,
title: String,
subtitle: Option<String>,
author: ShString22,
tags: Vec<ShString22>,
}
impl MdPost {
fn parse(file_name: &str, source: &str) -> Result<Self, ParseError> {
const END_OF_HEADER_DELIM: &str = "\n---\n";
let (header, md) = source.split_once(END_OF_HEADER_DELIM)
.ok_or(ParseError::MissingHeader)?;
let header = knuffel::parse::<HeaderNode>(file_name, header)
.map_err(|err| ParseError::InvalidHeader(Box::new(err)))?;
let md = md.trim_start();
Ok(Self {
markdown: md.to_owned(),
title: header.title,
subtitle: header.subtitle,
author: header.author.into(),
tags: header.tags.into_iter().map(|tag| tag.tag.into()).collect(),
})
}
}
#[derive(Debug)]
pub enum ParseError {
MissingHeader,
InvalidHeader(Box<knuffel::Error>),
IdTooLong(usize),
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingHeader => write!(f, "post file has no header"),
Self::InvalidHeader(err) => fmt::Display::fmt(err, f),
Self::IdTooLong(len) => write!(f, "post id too long ({} bytes)", len),
}
}
}
impl error::Error for ParseError {}
+5
View File
@@ -0,0 +1,5 @@
use chrono::{DateTime, NaiveDateTime, Utc};
pub fn unix_epoch() -> DateTime<Utc> {
DateTime::from_utc(NaiveDateTime::from_timestamp(0, 0), Utc)
}
+60
View File
@@ -0,0 +1,60 @@
use std::ops;
use knuffel::{
ast::{Literal, TypeName},
decode::{Context, Kind},
errors::{DecodeError, ExpectedType},
span::Spanned,
traits::ErrorSpan,
DecodeScalar,
};
#[derive(Clone, Copy, Default, Debug)]
#[repr(transparent)]
pub struct Uuid(pub libshire::uuid::Uuid);
impl Uuid {
pub fn as_inner(&self) -> &libshire::uuid::Uuid {
&self.0
}
}
impl ops::Deref for Uuid {
type Target = libshire::uuid::Uuid;
fn deref(&self) -> &Self::Target {
self.as_inner()
}
}
impl<S: ErrorSpan> DecodeScalar<S> for Uuid {
fn type_check(type_name: &Option<Spanned<TypeName, S>>, ctx: &mut Context<S>) {
if let Some(type_name) = type_name {
ctx.emit_error(DecodeError::TypeName {
span: type_name.span().clone(),
found: Some((&**type_name).clone()),
expected: ExpectedType::no_type(),
rust_type: "Uuid",
});
}
}
fn raw_decode(
value: &Spanned<Literal, S>,
ctx: &mut Context<S>,
) -> Result<Self, DecodeError<S>> {
match &**value {
Literal::String(s) => match s.parse() {
Ok(uuid) => Ok(Self(uuid)),
Err(err) => {
ctx.emit_error(DecodeError::conversion(value, err));
Ok(Default::default())
}
},
_ => {
ctx.emit_error(DecodeError::scalar_kind(Kind::String, value));
Ok(Default::default())
}
}
}
}