[#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

79
tavern/.rustfmt.toml Normal file
View File

@ -0,0 +1,79 @@
max_width = 80
hard_tabs = false
tab_spaces = 3
newline_style = "Unix"
indent_style = "Visual"
use_small_heuristics = "Default"
fn_call_width = 60
attr_fn_like_width = 70
struct_lit_width = 18
struct_variant_width = 35
array_width = 60
chain_width = 60
single_line_if_else_max_width = 50
single_line_let_else_max_width = 50
wrap_comments = true
format_code_in_doc_comments = true
doc_comment_code_block_width = 80
comment_width = 80
normalize_comments = true
normalize_doc_attributes = true
format_strings = true
format_macro_matchers = true
format_macro_bodies = true
skip_macro_invocations = []
hex_literal_case = "Preserve"
empty_item_single_line = true
struct_lit_single_line = true
fn_single_line = false
where_single_line = true
imports_indent = "Visual"
imports_layout = "Horizontal"
imports_granularity = "Module"
group_imports = "StdExternalCrate"
reorder_imports = true
reorder_modules = true
reorder_impl_items = true
type_punctuation_density = "Wide"
space_before_colon = false
space_after_colon = true
spaces_around_ranges = false
binop_separator = "Back"
remove_nested_parens = true
combine_control_expr = false
short_array_element_width_threshold = 10
overflow_delimited_expr = false
struct_field_align_threshold = 0
enum_discrim_align_threshold = 0
match_arm_blocks = true
match_arm_leading_pipes = "Never"
force_multiline_blocks = true
fn_params_layout = "Compressed"
brace_style = "AlwaysNextLine"
control_brace_style = "AlwaysNextLine"
trailing_semicolon = true
trailing_comma = "Never"
match_block_trailing_comma = false
blank_lines_upper_bound = 3
blank_lines_lower_bound = 0
edition = "2021"
style_edition = "2021"
inline_attribute_width = 0
format_generated_files = true
generated_marker_line_search_limit = 5
merge_derives = true
use_try_shorthand = false
use_field_init_shorthand = false
force_explicit_abi = true
condense_wildcard_suffixes = false
color = "Always"
required_version = "1.8.0"
unstable_features = true
disable_all_formatting = false
skip_children = false
show_parse_errors = true
error_on_line_overflow = false
error_on_unformatted = false
ignore = []
emit_mode = "Files"
make_backup = false

View File

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT OR IGNORE INTO tale_tags (tale_slug, tag_id)\n VALUES (?1, ?2)",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "0f58e58ff3bcda95267629c8dbde46f85218872afb759b07d5aa217309555b99"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO tavern (key, value) VALUES ('title', ?1)",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "2b2ddeb7ea1690d809f237afc8d21aa67599591bcbc9671d31c6ed90aded502d"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO adventurers (\n handle, name, profile, image, blurb\n ) VALUES (?1, ?2, ?3, ?4, ?5)",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "40a4dcf62e6741b1e2b669469704325d06de1327c453945128ce2e0a1edf510d"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "CREATE TABLE IF NOT EXISTS tags (\n id INTEGER PRIMARY KEY,\n name TEXT NOT NULL UNIQUE\n )",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "50d891dc85cb19ce33378ced606b10ac982c6fdcd30e6d089a1d140210c9b11a"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT OR IGNORE INTO tags (name) VALUES (?1)",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "5bbbe0b55b0e8a775165a59c2344bf5cbd7028ca60047a869de25e7931920190"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "CREATE TABLE IF NOT EXISTS tales (\n slug TEXT PRIMARY KEY,\n title TEXT NOT NULL,\n author TEXT NOT NULL,\n summary TEXT NOT NULL,\n publish_date TEXT NOT NULL,\n content TEXT NOT NULL\n )",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "63586c7d68985fedfb450a942ad03b81fe2f78bb0b317c75d9af6266c6ad6d55"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "CREATE TABLE IF NOT EXISTS adventurers (\n handle TEXT PRIMARY KEY,\n name TEXT NOT NULL,\n profile TEXT NOT NULL,\n image TEXT NOT NULL,\n blurb TEXT NOT NULL\n )",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "67c82ddcbec08a947ef28a28a439bbf3838edfc2e9ce27e6a6661505e5b936e2"
}

View File

@ -0,0 +1,20 @@
{
"db_name": "SQLite",
"query": "SELECT id FROM tags WHERE name = ?1",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true
]
},
"hash": "b1fa9c554e3fe18b4117a314c644cc5bf969e512b9fb6b589bd09504317363c0"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "CREATE TABLE IF NOT EXISTS tale_tags (\n tale_slug TEXT,\n tag_id INTEGER,\n FOREIGN KEY(tale_slug) REFERENCES tales(slug),\n FOREIGN KEY(tag_id) REFERENCES tags(id),\n UNIQUE(tale_slug, tag_id)\n )",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "c32d614137f871a3f603c57e99d62b293515e469653452750ed9e5424be00320"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO tales (\n slug, title, author, summary, publish_date, content\n ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "e448c3365fa62303d143b2ed04ee4e230b99d780768c96de7966fbee252e7565"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "CREATE TABLE IF NOT EXISTS tavern (\n key TEXT PRIMARY KEY,\n value TEXT NOT NULL\n )",
"describe": {
"columns": [],
"parameters": {
"Right": 0
},
"nullable": []
},
"hash": "ec49fe1746763238c7ead570da9b7800e68e1e7311c16ea07d9e50904b40e817"
}

View File

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT OR REPLACE INTO tavern (key, value) VALUES ('description', ?1)",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "ee6075930ca151fc036d2797b96b29c65de57982428e1a6f45579638b6c7442a"
}

2059
tavern/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

26
tavern/Cargo.toml Normal file
View File

@ -0,0 +1,26 @@
[package]
name = "tavern"
version = "0.1.0"
edition = "2024"
description = "A blogging system that will allow you to write your blog in Markdown and then display it in HTML using Dioxus."
repository = "/CyberMages/tavern"
authors = ["CyberMages LLC <Software@CyberMagesLLC.com>", "Jason Travis Smith <Myrddin@CyberMages.tech>"]
readme = "README.md"
license = "Apache-2.0"
[dependencies]
chrono = { version = "0.4.41", features = ["serde"] }
pulldown-cmark = "0.13.0"
serde = { version = "1.0.219", features = ["derive"] }
sqlx = { version = "0.8.6", features = ["sqlite", "chrono", "runtime-tokio"] }
toml = "0.9.5"
[dev-dependencies]
tokio = { version = "1", features = ["full"] }
[features]
publisher = []

174
tavern/LICENSE.md Normal file
View File

@ -0,0 +1,174 @@
# Apache License
Version 2.0, January 2004
<http://www.apache.org/licenses/>
## TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
### 1. Definitions.
**"License"** shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
**"Licensor"** shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
**"Legal Entity"** shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
**"You" (or "Your")** shall mean an individual or Legal Entity exercising
permissions granted by this License.
**"Source" form** shall mean the preferred form for making modifications,
including but not limited to software source code, documentation source,
and configuration files.
**"Object" form** shall mean any form resulting from mechanical
transformation or translation of a Source form, including but not
limited to compiled object code, generated documentation, and
conversions to other media types.
**"Work"** shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
**"Derivative Works"** shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
**"Contribution"** shall mean any work of authorship, including the
original version of the Work and any modifications or additions to that
Work or Derivative Works thereof, that is intentionally submitted to
the Licensor for inclusion in the Work by the copyright owner or by an
individual or Legal Entity authorized to submit on behalf of the
copyright owner.
**"Contributor"** shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
### 2. Grant of Copyright License.
Subject to the terms and conditions of this License, each Contributor
hereby grants to You a perpetual, worldwide, non-exclusive, no-charge,
royalty-free, irrevocable copyright license to reproduce, prepare
Derivative Works of, publicly display, publicly perform, sublicense,
and distribute the Work and such Derivative Works in Source or Object
form.
### 3. Grant of Patent License.
Subject to the terms and conditions of this License, each Contributor
hereby grants to You a perpetual, worldwide, non-exclusive, no-charge,
royalty-free, irrevocable (except as stated in this section) patent
license to make, have made, use, offer to sell, sell, import, and
otherwise transfer the Work, where such license applies only to those
patent claims licensable by such Contributor that are necessarily
infringed by their Contribution(s) alone or by combination of their
Contribution(s) with the Work to which such Contribution(s) was
submitted. If You institute patent litigation against any entity
(including a cross-claim or counterclaim in a lawsuit) alleging that
the Work or a Contribution incorporated within the Work constitutes
direct or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate as of
the date such litigation is filed.
### 4. Redistribution.
You may reproduce and distribute copies of the Work or Derivative Works
thereof in any medium, with or without modifications, and in Source or
Object form, provided that You meet the following conditions:
1. You must give any other recipients of the Work or Derivative Works a
copy of this License; and
2. You must cause any modified files to carry prominent notices stating
that You changed the files; and
3. You must retain, in the Source form of any Derivative Works that You
distribute, all copyright, patent, trademark, and attribution notices
from the Source form of the Work, excluding those notices that do not
pertain to any part of the Derivative Works; and
4. If the Work includes a "NOTICE" text file as part of its distribution,
then any Derivative Works that You distribute must include a readable
copy of the attribution notices contained within such NOTICE file,
excluding those notices that do not pertain to any part of the
Derivative Works, in at least one of the following places: within a
NOTICE text file distributed as part of the Derivative Works; within
the Source form or documentation, if provided along with the Derivative
Works; or, within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents of the
NOTICE file are for informational purposes only and do not modify the
License. You may add Your own attribution notices within Derivative
Works that You distribute, alongside or as an addendum to the NOTICE
text from the Work, provided that such additional attribution notices
cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may
provide additional or different license terms and conditions for use,
reproduction, or distribution of Your modifications, or for any such
Derivative Works as a whole, provided Your use, reproduction, and
distribution of the Work otherwise complies with the conditions stated
in this License.
### 5. Submission of Contributions.
Unless You explicitly state otherwise, any Contribution intentionally
submitted for inclusion in the Work by You to the Licensor shall be
under the terms and conditions of this License, without any additional
terms or conditions. Notwithstanding the above, nothing herein shall
supersede or modify the terms of any separate license agreement you
may have executed with Licensor regarding such Contributions.
### 6. Trademarks.
This License does not grant permission to use the trade names,
trademarks, service marks, or product names of the Licensor, except as
required for describing the origin of the Work and reproducing the
content of the NOTICE file.
### 7. Disclaimer of Warranty.
Unless required by applicable law or agreed to in writing, Licensor
provides the Work (and each Contributor provides its Contributions) on
an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
express or implied, including, without limitation, any warranties or
conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR
A PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
### 8. Limitation of Liability.
In no event and under no legal theory, whether in tort (including
negligence), contract, or otherwise, unless required by applicable law
(such as deliberate and grossly negligent acts) or agreed to in writing,
shall any Contributor be liable to You for damages, including any direct,
indirect, special, incidental, or consequential damages of any character
arising as a result of this License or out of the use or inability to use
the Work (including but not limited to damages for loss of goodwill, work
stoppage, computer failure or malfunction, or any and all other commercial
damages or losses), even if such Contributor has been advised of the
possibility of such damages.
### 9. Accepting Warranty or Additional Liability.
While redistributing the Work or Derivative Works thereof, You may choose
to offer, and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this License.
However, in accepting such obligations, You may act only on Your own behalf
and on Your sole responsibility, not on behalf of any other Contributor,
and only if You agree to indemnify, defend, and hold each Contributor
harmless for any liability incurred by, or claims asserted against, such
Contributor by reason of your accepting any such warranty or additional
liability.

19
tavern/README.md Normal file
View File

@ -0,0 +1,19 @@
# Tavern
A blogging system that will allow you to write your blog in Markdown and then
display it in HTML using Dioxus.
---
## Copyright & License
Copyright 2025 CyberMages LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this library except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS

View File

@ -0,0 +1,135 @@
use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use tavern::{Adventurer, Database, Legend, Lore, Tale, Tavern};
#[cfg(feature = "publisher")]
fn generate_tavern() -> Tavern
{
let legend: Legend = Legend
{
profile:
String::from("https://cybermages.tech/about/myrddin"),
image:
String::from("https://cybermages.tech/about/myrddin/pic"),
blurb: String::from("I love code!") };
let author: Adventurer =
Adventurer { name: String::from("Jason Smith"),
handle: String::from("myrddin"),
legend };
let lore: Lore =
Lore { title: String::from("Test post"),
slug: String::from("test_post"),
author: author.handle.clone(),
summary: String::from("The Moon is made of cheese!"),
tags: vec![String::from("Space"),
String::from("Cheese")],
publish_date:
NaiveDate::from_ymd_opt(2025, 12, 25).unwrap()
.and_hms_opt(13,
10, 41)
.unwrap() };
let tale: Tale = Tale { lore,
story: PathBuf::from("posts/test_post.md") };
// Create a dummy posts directory and file for this example to work
if !Path::new("posts").exists()
{
std::fs::create_dir("posts").unwrap();
}
std::fs::write("posts/the-rustacean.md",
"# Hello, Rust!\n\nThis is a **test** post.").unwrap();
Tavern { title: String::from("Runes & Ramblings"),
description: String::from("Join software engineer Jason Smith \
on his Rust programming journey. \
Explore program design, tech \
stacks, and more on this blog from \
CybeMages, LLC."),
tales: vec![tale],
authors: vec![author] }
}
#[cfg(feature = "publisher")]
fn read_from_file<P>(config_file: P) -> Tavern
where P: AsRef<Path>
{
// Read the previously written TOML file
let toml_data =
std::fs::read_to_string(&config_file).expect("Failed to read TOML file");
// Deserialize it
toml::from_str(&toml_data).expect("Failed to parse TOML")
}
#[cfg(feature = "publisher")]
async fn create_database() -> Result<(), Box<dyn std::error::Error>>
{
// This part would be the entry point of your CI/CD script
// It would load your data and then save it to the database
// Create a Tavern object
let _tavern = read_from_file("Tavern.toml");
// let tavern = generate_tavern();
// Open the database and save the Tavern content
let _db = Database::open("/home/myrddin/cybermages/blog/tavern.db").await?;
//db.insert_tavern(&tavern.title, &tavern.description)?;
//println!("Saved site settings: Title='{}', Description='{}'",
//tavern.title, tavern.description);
//for author in &tavern.authors
// {
// db.insert_adventurer(author)?;
// println!("Saved adventurer: {}", author.name);
// }
//
// for tale in &tavern.tales
// {
// db.insert_tale(tale)?;
// println!("Saved tale: {}", tale.title);
// }
Ok(())
}
#[cfg(feature = "publisher")]
#[tokio::main]
pub async fn main()
{
match std::env::set_current_dir("/home/myrddin/cybermages/blog/")
{
Ok(_) =>
{
println!("Successfully changed working directory.");
}
Err(e) =>
{
eprintln!("Failed to change directory: {}", e);
}
}
match create_database().await
{
Ok(_) =>
{}
Err(e) =>
{
eprintln!("Error: {}", e);
}
}
}
#[cfg(not(feature = "publisher"))]
pub fn main()
{
}

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>
}

102
tavern/tests/serde.rs Normal file
View File

@ -0,0 +1,102 @@
#![cfg(feature = "publisher")]
use chrono::NaiveDate;
use tavern::{Adventurer, FrontMatter, Tale, Tavern};
fn generate_tavern() -> Tavern
{
let author: Adventurer =
Adventurer { name: String::from("Jason Smith"),
handle: String::from("myrddin"),
profile:
String::from("https://cybermages.tech/about/myrddin"),
image:
String::from("https://cybermages.tech/about/myrddin/pic"),
blurb: String::from("I love code!") };
let fm: FrontMatter = FrontMatter {
title: String::from("Test post"),
slug: String::from("test_post"),
author: author.handle.clone(),
summary: String::from("The Moon is made of cheese!"),
tags: vec![String::from("Space"), String::from("Cheese")],
publish_date:
NaiveDate::from_ymd_opt(2025, 12, 25).unwrap()
.and_hms_opt(13, 10, 41)
.unwrap()
};
let tale: Tale = Tale {
front_matter: fm,
content: std::path::PathBuf::from("posts/test_post.md") };
Tavern { title: String::from("Runes & Ramblings"),
description: String::from("Join software engineer Jason Smith \
on his Rust programming journey. \
Explore program design, tech \
stacks, and more on this blog from \
CybeMages, LLC."),
tales: vec![tale],
authors: vec![author] }
}
fn cleanup_temp_file(path: &std::path::PathBuf)
{
if path.exists()
{
let _ = std::fs::remove_file(path);
}
}
#[test]
fn to_file()
{
let tavern = generate_tavern();
let toml_string = toml::to_string_pretty(&tavern).expect("Serialization \
to TOML should \
succeed");
// Save the TOML to a temporary file.
let mut path = std::env::temp_dir();
path.push("tavern_test_out.toml");
std::fs::write(&path, &toml_string).expect("Failed to write TOML to file");
cleanup_temp_file(&path);
}
#[test]
fn from_file()
{
let tavern = generate_tavern();
let toml_string = toml::to_string_pretty(&tavern).expect("Serialization \
to TOML should \
succeed");
// Save the TOML to a temporary file.
let mut path = std::env::temp_dir();
path.push("tavern_test_in.toml");
std::fs::write(&path, &toml_string).expect("Failed to write TOML to file");
// Read the previously written TOML file
let toml_data =
std::fs::read_to_string(&path).expect("Failed to read TOML file");
// Deserialize it
let tavern: Tavern =
toml::from_str(&toml_data).expect("Failed to parse TOML");
// Assert some known values to make this a real test
let tale = &tavern.tales[0];
assert_eq!(tale.front_matter.title, "Test post");
assert_eq!(tale.front_matter.slug, "test_post");
assert_eq!(tale.front_matter.author, "myrddin");
cleanup_temp_file(&path);
}