[#3] Split the tavern project into a workspace.

**tavern** - The blogging system library.
**loremaster** - Creates the database from a blogging repository.
**bard** - Dioxus components to display the blog.
This commit is contained in:
2025-08-29 20:43:36 -04:00
parent 7b5c69cc50
commit 3c6e82dfaf
40 changed files with 2766 additions and 33 deletions

40
tavern/src/adventurer.rs Normal file
View File

@ -0,0 +1,40 @@
use serde::{Deserialize, Serialize};
///
#[derive(Deserialize, Serialize)]
pub struct Legend
{
/// A link to the adventurer's profile (e.g., personal website, GitHub,
/// etc.).
pub profile: String,
/// A URL or path to an image representing the adventurer (e.g., avatar or
/// portrait).
pub image: String,
/// A short descriptive text or tagline about the adventurer.
pub blurb: String
}
/// Represents an author or contributor of a tale.
///
/// An `Adventurer` contains identifying and descriptive information
/// such as their name, handle, profile URL, image, and a short blurb.
#[derive(Deserialize, Serialize)]
pub struct Adventurer
{
/// The full name of the adventurer.
pub name: String,
/// A unique handle or username for the adventurer (e.g., used in URLs or
/// mentions).
pub handle: String,
///
#[serde(flatten)]
pub legend: Legend
}
impl Adventurer {}

44
tavern/src/converter.rs Normal file
View File

@ -0,0 +1,44 @@
use pulldown_cmark::{html, Parser};
pub enum Converter {}
impl Converter
{
/// Private function to handle the core conversion logic
pub fn markdown_to_html(markdown_content: &str) -> String
{
let parser = Parser::new(markdown_content);
let mut html_output = String::new();
html::push_html(&mut html_output, parser);
html_output
}
}
// tests/integration_test.rs
// This file would be in your project's `tests` directory.
// It tests the public functions of your main program.
// You will also need to add `use crate::*` to access the functions.
#[cfg(test)]
mod tests
{
use super::*;
#[test]
fn test_markdown_conversion()
{
// Create a temporary Markdown file for the test
let markdown_content = "# Hello, World!\n\nThis is a **test**.";
let expected_html =
"<h1>Hello, World!</h1>\n<p>This is a <strong>test</strong>.</p>\n";
// Use the new Converter API
let converted_html = Converter::markdown_to_html(&markdown_content);
// Assert that the output matches the expected HTML
assert_eq!(converted_html.trim(), expected_html.trim());
}
}

404
tavern/src/database.rs Normal file
View File

@ -0,0 +1,404 @@
use std::path::Path;
use std::str::FromStr;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions};
use sqlx::{Error, Result};
#[cfg(not(feature = "publisher"))]
use sqlx::Row;
use crate::adventurer::{Adventurer, Legend};
use crate::tale::Tale;
#[cfg(feature = "publisher")]
use crate::converter::Converter;
#[cfg(not(feature = "publisher"))]
use crate::tale::Lore;
/// Represents the database connection pool.
pub struct Database
{
pool: SqlitePool
}
impl Database
{
/// Opens a connection to the SQLite database pool and creates the necessary
/// tables.
///
/// db_path is an absolute path to the resource file.
/// Example:
/// ```text
/// open("/var/website/tavern.db");
/// ```
pub async fn open<P>(db_path: P) -> Result<Self>
where P: AsRef<Path>
{
let db_str =
db_path.as_ref().to_str().ok_or_else(|| {
Error::Configuration("Invalid UTF-8 in database \
path"
.into())
})?;
let url: String = format!("sqlite:///{db_str}");
// Set up connection options with foreign keys enabled.
#[cfg(feature = "publisher")]
let connect_options =
SqliteConnectOptions::from_str(&url)?.read_only(false)
.foreign_keys(true)
.create_if_missing(true);
#[cfg(not(feature = "publisher"))]
let connect_options =
SqliteConnectOptions::from_str(&url)?.read_only(true)
.foreign_keys(true)
.create_if_missing(false);
let pool = SqlitePoolOptions::new().connect_with(connect_options)
.await?;
let database = Database { pool };
#[cfg(feature = "publisher")]
database.create_tables().await?;
Ok(database)
}
/// Creates the 'tales', 'adventurers', 'tags', 'tale_tags', and 'tavern'
/// tables if they don't exist.
#[cfg(feature = "publisher")]
async fn create_tables(&self) -> Result<()>
{
sqlx::query!(
"CREATE TABLE IF NOT EXISTS adventurers (
handle TEXT PRIMARY KEY,
name TEXT NOT NULL,
profile TEXT NOT NULL,
image TEXT NOT NULL,
blurb TEXT NOT NULL
)"
).execute(&self.pool)
.await?;
sqlx::query!(
"CREATE TABLE IF NOT EXISTS tales (
slug TEXT PRIMARY KEY,
title TEXT NOT NULL,
author TEXT NOT NULL,
summary TEXT NOT NULL,
publish_date TEXT NOT NULL,
content TEXT NOT NULL
)"
).execute(&self.pool)
.await?;
sqlx::query!(
"CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE
)"
).execute(&self.pool)
.await?;
sqlx::query!(
"CREATE TABLE IF NOT EXISTS tale_tags (
tale_slug TEXT,
tag_id INTEGER,
FOREIGN KEY(tale_slug) REFERENCES tales(slug),
FOREIGN KEY(tag_id) REFERENCES tags(id),
UNIQUE(tale_slug, tag_id)
)"
).execute(&self.pool)
.await?;
sqlx::query!(
"CREATE TABLE IF NOT EXISTS tavern (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
)"
).execute(&self.pool)
.await?;
Ok(())
}
/// Inserts a single tale into the database.
#[cfg(feature = "publisher")]
pub async fn insert_tale(&self, tale: &Tale)
-> Result<(), Box<dyn std::error::Error>>
{
// Convert the tales content from Markdown to HTML.
let markdown_content = std::fs::read_to_string(&tale.story)?;
let html_content: String = Converter::markdown_to_html(&markdown_content);
// Start a transaction.
let mut tx = self.pool.begin().await?;
// Store the tale.
sqlx::query!(
"INSERT OR REPLACE INTO tales (
slug, title, author, summary, publish_date, content
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
tale.lore.slug,
tale.lore.title,
tale.lore.author,
tale.lore.summary,
tale.lore.publish_date,
html_content
).execute(&mut *tx) // Pass mutable reference to the transaction
.await?;
// Store the tags.
// For each tag ...
for tag_name in &tale.lore.tags
{
// Insert a new tag, ignore if it already exists.
sqlx::query!("INSERT OR IGNORE INTO tags (name) VALUES (?1)",
tag_name).execute(&mut *tx) // Pass mutable reference to the transaction
.await?;
// Get the tag_id for the newly inserted or existing tag.
let id: i64 = sqlx::query!("SELECT id FROM tags WHERE name = ?1",
tag_name).fetch_one(&mut *tx) // Pass mutable reference to the transaction
.await?
.id
.unwrap_or(0); // Use unwrap_or to handle the Option<i64>
// Insert the tale_tag relationship.
sqlx::query!(
"INSERT OR IGNORE INTO tale_tags (tale_slug, tag_id)
VALUES (?1, ?2)",
tale.lore.slug,
id
).execute(&mut *tx) // Pass mutable reference to the transaction
.await?;
}
// Commit the transaction.
tx.commit().await?;
Ok(())
}
/// Inserts a single adventurer into the database.
#[cfg(feature = "publisher")]
pub async fn insert_adventurer(&self, adventurer: &Adventurer)
-> Result<()>
{
// Start a transaction.
let mut tx = self.pool.begin().await?;
sqlx::query!(
"INSERT OR REPLACE INTO adventurers (
handle, name, profile, image, blurb
) VALUES (?1, ?2, ?3, ?4, ?5)",
adventurer.handle,
adventurer.name,
adventurer.legend.profile,
adventurer.legend.image,
adventurer.legend.blurb
).execute(&mut *tx)
.await?;
// Commit the transaction.
tx.commit().await?;
Ok(())
}
/// Inserts the site-wide settings like title and description.
#[cfg(feature = "publisher")]
pub async fn insert_tavern(&self, title: &str, description: &str)
-> Result<()>
{
// Start a transaction.
let mut tx = self.pool.begin().await?;
// Insert or replace the title.
sqlx::query!("INSERT OR REPLACE INTO tavern (key, value) VALUES \
('title', ?1)",
title).execute(&mut *tx) // Pass mutable reference to the transaction
.await?;
// Insert or replace the description.
sqlx::query!("INSERT OR REPLACE INTO tavern (key, value) VALUES \
('description', ?1)",
description).execute(&mut *tx) // Pass mutable reference to the transaction
.await?;
// Commit the transaction.
tx.commit().await?;
Ok(())
}
#[cfg(not(feature = "publisher"))]
pub async fn get_tales_summary(&self, categories: &[String])
-> Result<Vec<Lore>>
{
let mut tales = Vec::new();
// Start a read-only transaction.
let mut tx = self.pool.begin().await?;
// Dynamically build the query.
let mut query = String::from(
"SELECT
t.title,
t.slug,
t.summary,
t.author,
t.publish_date,
GROUP_CONCAT(tg.name, ',') AS tags
FROM tales AS t
LEFT JOIN tale_tags AS tt ON t.slug = tt.tale_slug
LEFT JOIN tags AS tg ON tt.tag_id = tg.id"
);
if !categories.is_empty()
{
query.push_str(" WHERE tg.name IN (");
let placeholders: Vec<_> =
(0..categories.len()).map(|_| "?").collect();
query.push_str(&placeholders.join(", "));
query.push(')');
}
query.push_str(" GROUP BY t.slug ORDER BY t.publish_date DESC");
let mut q = sqlx::query(&query);
for cat in categories
{
q = q.bind(cat);
}
let rows = q.fetch_all(&mut *tx).await?;
for row in rows
{
let tags_str: Option<String> = row.try_get("tags")?;
let tags = tags_str.map(|s| s.split(',').map(String::from).collect())
.unwrap_or_default();
let date_str: String = row.try_get("publish_date")?;
let publish_date = chrono::NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%dT%H:%M:%S")
.map_err(|e| sqlx::Error::Decode(e.into()))?;
tales.push(Lore { title: row.try_get("title")?,
slug: row.try_get("slug")?,
summary: row.try_get("summary")?,
author: row.try_get("author")?,
publish_date,
tags });
}
tx.commit().await?; // Explicit commit, even for read transactions.
Ok(tales)
}
#[cfg(not(feature = "publisher"))]
pub async fn get_tale_by_slug(&self, slug: &str) -> Result<Option<Tale>>
{
let mut tx = self.pool.begin().await?;
let tale_row = sqlx::query(
"SELECT
t.title, t.slug, t.summary, t.author, t.publish_date,
t.content,
GROUP_CONCAT(tg.name, ',') AS tags
FROM tales AS t
LEFT JOIN tale_tags AS tt ON t.slug = tt.tale_slug
LEFT JOIN tags AS tg ON tt.tag_id = tg.id
WHERE t.slug = ?1
GROUP BY t.slug"
).bind(slug)
.fetch_optional(&mut *tx) // Use transaction here
.await?;
tx.commit().await?;
if let Some(row) = tale_row
{
let tags_str: Option<String> = row.try_get("tags")?;
let tags = tags_str.map(|s| s.split(',').map(String::from).collect())
.unwrap_or_default();
let date_str: String = row.try_get("publish_date")?;
let publish_date = chrono::NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%dT%H:%M:%S")
.map_err(|e| Error::Decode(e.into()))?;
let lore = Lore { title: row.try_get("title")?,
slug: row.try_get("slug")?,
summary: row.try_get("summary")?,
author: row.try_get("author")?,
publish_date,
tags };
Ok(Some(Tale { lore,
story: row.try_get("content")? }))
}
else
{
Ok(None)
}
}
#[cfg(not(feature = "publisher"))]
pub async fn get_adventurer(&self, handle: &str)
-> Result<Option<Adventurer>>
{
let mut tx = self.pool.begin().await?;
let legend = sqlx::query_as!(
Legend,
"SELECT
profile AS profile,
image AS image,
blurb AS blurb
FROM adventurers
WHERE handle = ?1",
handle
)
.fetch_optional(&mut *tx)
.await?;
let hero = sqlx::query!(
"SELECT
name,
handle AS 'handle!'
FROM adventurers
WHERE handle = ?1",
handle
)
.fetch_optional(&mut *tx)
.await?;
tx.commit().await?;
let adventurer = match (hero, legend)
{
(Some(h), Some(l)) =>
{
Some(Adventurer
{
name: h.name,
handle: h.handle,
legend: l,
})
}
_ => { None }
};
Ok(adventurer)
}
}

33
tavern/src/info.rs Normal file
View File

@ -0,0 +1,33 @@
//! This is where the cargo build information can be retrieved from.
/// The environment variable defined by Cargo for the name.
const NAME: Option<&str> = option_env!("CARGO_PKG_NAME");
/// The environment variable defined by Cargo for the version.
const VERSION: Option<&str> = option_env!("CARGO_PKG_VERSION");
/// The string to display if a value is not defined during compile time.
const NOT_DEFINED: &'static str = "UNDEFINED";
/// Returns the name of the program as defined by the CARGO_PKG_NAME. This is
/// set at compile time and comes from the Cargo.toml file.
///
/// If a value is not found, then it will return the not defined value.
pub fn get_name() -> &'static str
{
NAME.unwrap_or(NOT_DEFINED)
}
/// Returns the version of the program as defined by the CARGO_PKG_VERSION.
/// This is set at compile time and comes from the Cargo.toml file.
///
/// If a value is not found, then it will return the not defined value.
pub fn get_version() -> &'static str
{
VERSION.unwrap_or(NOT_DEFINED)
}

19
tavern/src/lib.rs Normal file
View File

@ -0,0 +1,19 @@
//! A blogging system that will allow you to write your blog in Markdown and
//! then display it in HTML using Dioxus.
mod info;
mod adventurer;
#[cfg(feature = "publisher")]
mod converter;
mod database;
mod tale;
mod tavern;
pub use crate::adventurer::{Adventurer, Legend};
pub use crate::database::Database;
pub use crate::info::{get_name, get_version};
pub use crate::tale::{Lore, Tale};
pub use crate::tavern::Tavern;

56
tavern/src/tale.rs Normal file
View File

@ -0,0 +1,56 @@
use chrono::NaiveDateTime;
use serde::{Deserialize, Serialize};
/// A type alias representing the path to a Markdown file.
/// This type is used to point to the location of the content of a `Tale`.
#[cfg(feature = "publisher")]
pub type Markdown = std::path::PathBuf;
/// A type alias representing the HTML content of the tale.
#[cfg(not(feature = "publisher"))]
pub type Markdown = String;
/// Metadata describing a tale.
///
/// This includes details such as the title, author, summary, and
/// associated tags.
#[derive(Deserialize, Serialize)]
pub struct Lore
{
/// The title of the tale.
pub title: String,
/// A URL-friendly version of the title, used for routing and linking.
pub slug: String,
/// The name of the author who wrote the tale.
pub author: String,
/// A short summary or description of the tale.
pub summary: String,
/// A list of tags associated with the tale for categorization and
/// searching.
pub tags: Vec<String>,
/// The Date and Time that must elapse before this tale can be told.
pub publish_date: NaiveDateTime
}
/// Represents a post or story in the application.
#[derive(Deserialize, Serialize)]
pub struct Tale
{
/// Metadata of the post.
#[serde(flatten)]
pub lore: Lore,
/// The file path to the Markdown content of the tale.
pub story: Markdown
}

27
tavern/src/tavern.rs Normal file
View File

@ -0,0 +1,27 @@
use serde::{Deserialize, Serialize};
use crate::adventurer::Adventurer;
use crate::tale::Tale;
/// Represents the entire blog or collection of content.
///
/// A `Tavern` contains a list of all tales (posts) and their respective
/// authors. It serves as the central structure for organizing and serializing
/// the site's content.
#[derive(Deserialize, Serialize)]
pub struct Tavern
{
///
pub title: String,
///
pub description: String,
/// A list of all published tales (posts) in the tavern.
pub tales: Vec<Tale>,
/// A list of all adventurers (authors) who have contributed tales.
pub authors: Vec<Adventurer>
}