Compare commits

...

36 Commits

Author SHA1 Message Date
53e62fcb68 I just bumped the version so I could test.
All checks were successful
Build Tavernworks / Explore-Gitea-Actions (push) Successful in 3m22s
I needed a fast test to make sure the Gitea server repository was
working.
2025-10-21 12:17:38 -04:00
04c2182705 The logos for the project.
All checks were successful
Build Tavernworks / Explore-Gitea-Actions (push) Successful in 2m41s
2025-10-17 10:29:41 -04:00
f023f36558 Adjusted the responsive layout targets.
All checks were successful
Build Tavernworks / Explore-Gitea-Actions (push) Successful in 3m11s
2025-10-16 18:47:20 -04:00
62a2c4f5bc Fixed a spelling mistake in the blog css.
All checks were successful
Build Tavernworks / Explore-Gitea-Actions (push) Successful in 3m11s
2025-10-16 18:32:01 -04:00
87bb1459b8 Bumped the bard version for publishing.
All checks were successful
Build Tavernworks / Explore-Gitea-Actions (push) Successful in 3m49s
2025-10-16 13:58:36 -04:00
8dd20cb0cb Fixed responsiveness of the blog.
All checks were successful
Build Tavernworks / Explore-Gitea-Actions (push) Successful in 2m44s
Also added the publish date to a post.
2025-10-16 13:56:58 -04:00
55f1ac22b0 Changed the target dir tot he home directory bin.
All checks were successful
Build Tavernworks / Explore-Gitea-Actions (push) Successful in 2m37s
2025-10-11 20:43:47 -04:00
c3ae332b0b Changed the target directory to place executables.
Some checks failed
Build Tavernworks / Explore-Gitea-Actions (push) Failing after 2m38s
2025-10-11 20:34:23 -04:00
dede2e9072 Fixed the build script to use this repository.
Some checks failed
Build Tavernworks / Explore-Gitea-Actions (push) Failing after 2m42s
2025-10-11 20:27:26 -04:00
4c03aea947 Fixing the build script for gitea.
Some checks failed
Build Tavernworks / Explore-Gitea-Actions (push) Failing after 1m49s
2025-10-11 20:19:40 -04:00
ee82498161 Adding the build testing script.
Some checks failed
Build Tavernworks / Explore-Gitea-Actions (push) Failing after 7s
This will also install loreweaver for use by the blog.
2025-10-11 18:43:17 -04:00
d822ecdf84 Fixed up the OpenGraph meta. 2025-10-11 18:38:46 -04:00
e8a4c19fdb Post page now is completely done in SSR.
This was done to make loading time faster as it's all static information
and to make it so blog posts can be linked to and get OpenGraph data.
2025-10-10 11:19:14 -04:00
1a57f3d143 openGraph image now set properly to default image. 2025-10-10 01:24:41 -04:00
1a82e856d9 use_resource is the correct thing to use.
The top components can use_resource and be properly setup during
hydration. The sub components need to use_server_resource so that they
can be hydrated later.
2025-10-10 00:26:17 -04:00
e49ecbac4d The TagSelector needed to stay a use_resource. 2025-10-10 00:01:31 -04:00
bca3e9f939 Switching all server function calls.
Now server function calls use, use_server_future. It seems to be working
now, unlike in 0.6.3
2025-10-09 23:49:35 -04:00
3fe3d874ca Added the ability to specify images.
The blog title can now be set to be an image. If not it will default to
the text. If no text it will just be "Blog".

A Post has now been given a default post image that will be used for all
posts for their openGraph sharing. This can later be expanded to allow
a blog post to have a desired image.
2025-10-09 17:00:56 -04:00
725824003b Updated to use the new Dioxus v0.7.0-rc.1
The system is using the nightly toolchain now due to Dioxus needing it.
2025-10-08 15:17:48 -04:00
ce80af94ee Cleaned up some Author related displays.
It was determined to use the Author's desired name and no handle. The
handle will only be used for unique linking.

Also, the CyberMages LLC was dropped from the Author metadata until a
company can be specified per Author. Not all users will be associated
with CyberMages.
2025-10-07 11:31:07 -04:00
cba2e95290 Doing some overall warning cleanup. 2025-09-28 20:25:19 -04:00
468b9449b2 Client side settings can now be specified.
Here we use a context provider on the app itself to handle passing the
settings to our library. This way even the layout can use them.
2025-09-28 15:12:03 -04:00
6efec6bd22 TagList and TagItem are now used in the post nav.
The TagItem is now used properly in the post nav area. Only the
ToggleTags are different now.

Bumped the patch version.
2025-09-27 14:22:26 -04:00
3f4440ec2f Switching to a single stylesheet.
Also bumped the bard version.
2025-09-27 13:29:08 -04:00
e7969c8050 Fixed the TagSelector reactivity.
use_server_future was not reliably re-running when url_tag changed during
direct URL navigation, causing tags to remain unselected. use_resource
provides consistent reactivity across all navigation methods and handles
both data fetching and selection logic atomically.
2025-09-27 13:21:56 -04:00
c73c99bf48 Added that tavern is from the cybermages registry. 2025-09-25 11:45:04 -04:00
40b211d06c Bumped the version for tavern. 2025-09-25 11:43:31 -04:00
fd51c7c0bb Bumping all versions to the same minor version. 2025-09-25 11:39:56 -04:00
c5c433edb5 Adding initial README files. 2025-09-25 11:38:05 -04:00
54dc751dca Fixing the Router for the exmaple. 2025-09-25 11:37:36 -04:00
655dc8e90f Changing loremaster to loreweaver. 2025-09-25 11:36:59 -04:00
6ec711f7ed Added the blog_test harness to the workspace. 2025-09-24 19:54:50 -04:00
6acbe95024 This is a test project for Bard.
This was done because setting up a test even as an example was hard with
the dependencies and other things. It may be doable, but the time
required was not worth it.
2025-09-24 19:52:25 -04:00
8f61185434 Adding and moving a lot of components.
This is the main thrust of the library. I know it is a lot of changes,
but I was running out of time and had to hammer them all in.
2025-09-24 19:50:38 -04:00
f752fa17bf Created the loremaster.
This is an application that uses tavern to create/publish a database
from a blog repository.
2025-09-24 19:40:57 -04:00
01dacabc03 Adjusted the tale summary retrieval.
The SQL was not properly searching the tales. It had been searching and
finding all the tags that matched the search criteria, but it was
dropping all the other tag information. Now it properly keeps all the
tag information. This fixed an issue where the tags of a post were
disapearing and reapearing as the TagSelector was toggled.
2025-09-24 19:36:29 -04:00
57 changed files with 4923 additions and 1353 deletions

View File

@ -0,0 +1,41 @@
name: Build Tavernworks
run-name: Building on Silverymoon by ${{ gitea.actor }}.
on:
push:
branches:
- main
jobs:
Explore-Gitea-Actions:
runs-on: FreeBSD-14.1
env:
SOURCE_DIR: tavernworks
TARGET_DIR: /home/gitea/bin
steps:
- name: Setup Workspace
run: |
rm -rf ${{ github.workspace }}/*
echo "Source Dir: $SOURCE_DIR"
- name: Clone Repo
uses: https://gitea.com/actions/checkout@v4
with:
repository: CyberMages/tavernworks
token: ${{ secrets.CM_GIT_TOKEN }}
path: ${{ env.SOURCE_DIR }}
- name: Build Repo
run: |
pwd
ls -la
cd "$SOURCE_DIR"
echo "Current Shell: $SHELL"
echo "PATH=$PATH"
. ~/.profile
echo "PATH=$PATH"
echo "Building release."
cargo build --release
echo "Installing loreweaver."
cp ./target/release/loreweaver "$TARGET_DIR"
- name: Clean Workspace
run: |
rm -rf ${{ github.workspace }}/*
ls -la

3233
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,19 @@
members = [
"tavern",
"bard",
"loremaster"
]
"loreweaver",
"blog_test"]
resolver = "2" # Enables modern dependency resolution
[profile]
[profile.wasm-dev]
inherits = "dev"
opt-level = 1
[profile.server-dev]
inherits = "dev"
[profile.android-dev]
inherits = "dev"

49
README.md Normal file
View File

@ -0,0 +1,49 @@
# Tavernworks
Tavernworks is a modular blog system written in Rust. It is designed to parse,
compile, and serve structured blog content using a cohesive set of themed
libraries. The system is centered around the `tavern` content engine and is
supported by a publisher CLI and a rendering frontend.
## Overview
TavernWorks consists of the following components:
### tavern
The core library that defines the data model, parses content, and provides the
API to read and write blog data. It is responsible for turning a repository of
Markdown files and metadata into an in-memory structure or database.
### loreweaver
A command-line tool that uses `tavern` to compile a blog repository into a
SQLite database. It reads a `Tavern.toml` metadata file and outputs a portable
database that can be served or inspected.
### bard
A Dioxus-based frontend library that renders blog content from the database.
It provides components to create a full blog UI using data produced by
`loreweaver`.
### blog_test
A test project that verifies the full system from content to rendering. It is
used to validate that blog repositories are parsed, compiled, and rendered
correctly across all components.
---
## 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

View File

@ -1,6 +1,6 @@
[package]
name = "bard"
version = "0.0.23"
version = "0.3.17"
edition = "2024"
description = "Dioxus components that will display a Tavern blogging system Blog."
repository = "/CyberMages/tavern"
@ -9,11 +9,17 @@ authors = ["CyberMages LLC <Software@CyberMagesLLC.com>",
readme = "README.md"
license = "Apache-2.0"
[[example]]
name = "blog"
path = "examples/blog.rs"
[dependencies]
dioxus = { version = "*", features = ["router", "fullstack"], optional = true }
tavern = { version = "0.2.9", path = "../tavern", registry = "cybermages", optional = true}
tokio = { version = "1.0", features = ["full"], optional = true }
dioxus = { version = "=0.7.0-rc.1", features = ["router", "fullstack"] }
tavern = { version = "0.3.0", path = "../tavern", registry="cybermages", optional = true}
tokio = { version = "1.0", features = ["rt", "macros"], optional = true }
[features]
default = ["tavern", "dioxus/web"]
default = ["tavern"]
web = ["tavern", "dioxus/web"]
server = ["tavern/database", "dioxus/server", "tokio"]

View File

@ -1,7 +1,72 @@
# Tavern
# Bard
A blogging system that will allow you to write your blog in Markdown and then
display it in HTML using Dioxus.
Bard is the frontend rendering library for the Tavernworks blog system.
It provides Dioxus components for rendering blog content that has been compiled
into a SQLite database by Loreweaver and parsed by the Tavern engine.
Bard supports both server-side and client-side rendering and is designed to be
integrated into Dioxus applications.
## Features
- Dioxus components for routing and rendering blog pages
- Works with content stored in a SQLite database
- Compatible with `dioxus-web` and `dioxus-desktop`
- Optional server integration via Axum for local hosting
## Usage
To use Bard in a Dioxus application, you must:
1. Initialize the database using `bard::init_database`.
2. Add the `Router::<bard::Page>` component to your app.
3. Optionally, define routes that include Bard under your own layout.
### Example
```rust
#[component]
fn App() -> Element {
rsx! {
Router::<Page> {}
}
}
#[derive(Routable, Clone, PartialEq)]
pub enum Page {
#[layout(Navbar)]
#[route("/")]
Home {},
#[child("/blog")]
Bard
{
child: bard::Page
},
#[route("/:..route")]
PageNotFound { route: Vec<String> }
}
```
### Server-side Initialization
To serve the blog using Axum and load the database:
```rust
tokio::runtime::Runtime::new()
.unwrap()
.block_on(async move {
bard::init_database("path/to/tavern.db").await;
});
```
## Features
Enable the following Cargo features as needed:
- `server`: Enables Axum support for database-backed serving
- `web`: Enables Dioxus web support
---

View File

@ -1,3 +1,16 @@
.visually_hidden
{
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
border: 0 !important;
white-space: nowrap !important;
}
.blog_style
{
display: flex;
@ -17,3 +30,257 @@
container-type: inline-size;
}
}
.blog_list
{
display: flex;
flex-direction: column;
width: 100%;
order: 5;
.blog_title
{
margin-top: 0px;
color: var(--accent-color);
}
.blog_title_image
{
width: 23rem;
align-self: center;
margin-bottom: 25px;
}
.blog_item
{
margin-bottom: 30px;
h2
{
margin: 0px;
color: var(--accent-color);
a:link, a:visited
{
color: var(--accent-color);
}
a:hover
{
color: var(--text-color);
}
}
p
{
margin: 5px 0px 0px 0px;
display: block;
}
}
}
.blog_post
{
order: 5;
h1
{
margin: 0px;
color: var(--accent-color);
}
h4
{
margin-top: 10px;
margin-bottom: 30px;
}
li
{
margin-bottom: 5px;
}
p
{
margin-top: 0px;
margin-bottom: 20px;
}
.blog_post_tale
{
pre
{
padding-left: 20px;
}
.embedded_video
{
margin-top: 50px;
display: flex;
align-content: center;
justify-content: center;
iframe
{
box-shadow: 0 0 80px var(--accent-color);
}
}
}
}
/* Tags will have a class of "tag_{tag_name}" that allows specific
* customization.
*
* .tag_easy
* {
* background-color: #248721;
* }
*
* */
.blog_tag
{
display: flex;
flex-direction: row;
.tag_list
{
display: flex;
flex-direction: row;
}
}
.blog_nav
{
order: 1;
display: flex;
flex-direction: column;
margin-top: 185px;
margin-right: 70px;
position:sticky;
top: 150px;
height: 50vh;
fieldset
{
border: none;
padding: 0;
margin: 0;
min-inline-size: unset;
}
legend
{
min-width: 130px;
padding: 0;
margin: 0;
font-weight: bold;
}
.tag_list
{
display: flex;
flex-direction: column;
}
.toggle_button {
display: inline-block;
margin-top: 5px;
margin-right: 5px;
padding: 0px 6px;
border: 2px solid var(--text-color);
border-radius: 5px;
color: var(--text-color);
cursor: pointer;
user-select: none;
transition: 0.1s ease-in;
}
.toggle_button:hover {
color: var(--accent-color);
border-color: var(--accent-color);
transform: translateY(-2px);
}
.toggle_button input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
/* Checked state using :has() — modern browsers only */
.toggle_button:has(input:checked) {
color: var(--accent-color);
border-color: var(--accent-color);
}
@container site (max-width: 1230px)
{
display: none;
}
}
.tag_list
{
list-style: none;
padding: 0px;
margin: 0px;
.tag_item
{
margin-top: 5px;
margin-right: 5px;
a
{
display: inline-block;
padding: 0px 4px;
text-decoration: none;
border: 2px solid var(--text-color);
border-radius: 5px;
color: var(--text-color);
transition: 0.1s ease-in;
&:hover
{
color: var(--accent-color);
border: 2px solid var(--accent-color);
transform: translateY(-2px);
}
}
}
}
.video_link
{
display: none;
width: 100%;
justify-content: center;
}
@media screen and (max-width: 1000px)
{
.blog_post .blog_post_tale
{
.video_link
{
display: flex;
}
.embedded_video
{
display: none;
}
}
}

View File

@ -1,230 +0,0 @@
.blog_style
{
display: flex;
width: 100%;
height: 100%;
background: var(--bg-color);
padding: 50px 0px;
justify-content: center;
container-name: site;
container-type: inline-size;
.page_content
{
display: flex;
width: 67%;
container-name: page;
container-type: inline-size;
}
}
.blog_nav
{
width: 15%;
padding: 0px 20px;
h3
{
margin-bottom: 10px;
}
ul
{
list-style: none;
li
{
}
}
@container site (max-width: 1230px)
{
display: none;
}
.social
{
/* Hide .social until the font is loaded */
display: flex;
align-items: center;
gap: 15px;
margin: 25px 0;
font-family: cm_social;
a
{
color: var(--text-color);
font-size: 46px;
text-decoration: none;
background: transparent;
width: 65px;
height: 65px;
border-radius: 50%;
display: flex;
align-items: flex-end;
justify-content: center;
transition: 0.4s ease;
border: 4px solid var(--text-color);
&:hover
{
color: var(--accent-color);
border: 4px solid var(--accent-color);
transform: translateY(-7px);
}
}
}
}
}
.blog_item_area
{
.blog_list
{
list-style: none;
.blog_item
{
margin-bottom: 50px;
h1
{
color: var(--accent-color);
}
h4
{
margin: 10px 0px;
}
p
{
display: block;
}
}
}
}
.blog_article
{
h1
{
color: var(--accent-color);
}
h4
{
margin-top: 10px;
margin-bottom: 30px;
}
p
{
margin-bottom: 20px;
}
.embeded_video
{
margin-top: 50px;
display: flex;
align-content: center;
justify-content: center;
iframe
{
box-shadow: 0 0 80px var(--accent-color);
}
}
}
.tag_style
{
display: block;
.tag_list
{
list-style: none;
.tag_item
{
display: inline-block;
margin: 5px 5px 0px 0px;
a
{
display: block;
padding: 0px 4px;
text-decoration: none;
border: 2px solid var(--text-color);
border-radius: 5px;
color: var(--text-color);
transition: 0.1s ease-in;
&:hover
{
color: var(--accent-color);
border: 2px solid var(--accent-color);
transform: translateY(-2px);
}
}
}
}
}
.easy
{
background-color: #248721;
}
.medium
{
background-color: #d6a318;
}
.hard
{
background-color: #d92121;
}
.social
{
background: transparent;
font-family: cm_social;
margin: 15px 0px;
a
{
color: var(--text-color);
font-size: 30px;
text-decoration: none;
background: transparent;
width: 45px;
height: 45px;
border-radius: 50%;
display: flex;
align-items: flex-end;
justify-content: center;
transition: 0.4s ease;
border: 4px solid var(--text-color);
&:hover
{
color: var(--accent-color);
border: 4px solid var(--accent-color);
transform: translateY(-7px);
}
}
}

View File

@ -1,27 +0,0 @@
.blog_list_style
{
.blog_list
{
list-style: none;
.blog_item
{
margin-bottom: 50px;
h1
{
color: var(--accent-color);
}
h4
{
margin: 10px 0px;
}
p
{
display: block;
}
}
}
}

View File

@ -1,117 +0,0 @@
.blog_style
{
display: flex;
width: 100%;
height: 100%;
background: var(--bg-color);
padding: 50px 0px;
justify-content: center;
container-name: site;
container-type: inline-size;
.page_content
{
display: flex;
width: 67%;
container-name: page;
container-type: inline-size;
}
}
.blog_nav_style
{
width: 15%;
padding: 0px 20px;
order: 5;
h3
{
margin-bottom: 10px;
}
ul
{
list-style: none;
li
{
}
}
@container site (max-width: 1230px)
{
display: none;
}
.social
{
/* Hide .social until the font is loaded */
display: flex;
align-items: center;
gap: 15px;
margin: 25px 0;
font-family: cm_social;
a
{
color: var(--text-color);
font-size: 22px;
text-decoration: none;
background: transparent;
width: 35px;
height: 35px;
border-radius: 50%;
display: flex;
align-items: flex-end;
justify-content: center;
transition: 0.4s ease;
border: 4px solid var(--text-color);
&:hover
{
color: var(--accent-color);
border: 4px solid var(--accent-color);
transform: translateY(-7px);
}
}
}
.tag_style
{
display: block;
.tag_list
{
display: inline-flex;
flex-direction: column;
list-style: none;
.tag_item
{
display: inline-block;
margin: 5px 5px 0px 0px;
a
{
display: inline-flex;
padding: 0px 4px;
text-decoration: none;
border: 2px solid var(--text-color);
border-radius: 5px;
color: var(--text-color);
transition: 0.1s ease-in;
&:hover
{
color: var(--accent-color);
border: 2px solid var(--accent-color);
transform: translateY(-2px);
}
}
}
}
}
}

View File

@ -1,119 +0,0 @@
.blog_post_style
{
order: 1;
h1
{
color: var(--accent-color);
}
h4
{
margin-top: 10px;
margin-bottom: 30px;
}
p
{
margin-bottom: 20px;
}
.embeded_video
{
margin-top: 50px;
display: flex;
align-content: center;
justify-content: center;
iframe
{
box-shadow: 0 0 80px var(--accent-color);
}
}
}
.tag_style
{
display: block;
.tag_list
{
list-style: none;
.tag_item
{
display: inline-block;
margin: 5px 5px 0px 0px;
a
{
display: block;
padding: 0px 4px;
text-decoration: none;
border: 2px solid var(--text-color);
border-radius: 5px;
color: var(--text-color);
transition: 0.1s ease-in;
&:hover
{
color: var(--accent-color);
border: 2px solid var(--accent-color);
transform: translateY(-2px);
}
}
}
}
}
.easy
{
background-color: #248721;
}
.medium
{
background-color: #d6a318;
}
.hard
{
background-color: #d92121;
}
.social
{
background: transparent;
font-family: cm_social;
margin: 15px 0px;
a
{
color: var(--text-color);
font-size: 30px;
text-decoration: none;
background: transparent;
width: 45px;
height: 45px;
border-radius: 50%;
display: flex;
align-items: flex-end;
justify-content: center;
transition: 0.4s ease;
border: 4px solid var(--text-color);
&:hover
{
color: var(--accent-color);
border: 4px solid var(--accent-color);
transform: translateY(-7px);
}
}
}

View File

@ -1,54 +0,0 @@
.blog_tag_style
{
display: block;
.tag_list
{
list-style: none;
.tag_item
{
display: inline-block;
margin: 5px 5px 0px 0px;
a
{
display: block;
padding: 0px 4px;
text-decoration: none;
border: 2px solid var(--text-color);
border-radius: 5px;
color: var(--text-color);
transition: 0.1s ease-in;
&:hover
{
color: var(--accent-color);
border: 2px solid var(--accent-color);
transform: translateY(-2px);
}
}
}
}
.none
{
background-color: var(--bg-color);
}
.easy
{
background-color: #248721;
}
.medium
{
background-color: #d6a318;
}
.hard
{
background-color: #d92121;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

View File

@ -1,496 +0,0 @@
use std::borrow::Borrow;
use std::collections::HashSet;
use std::hash::Hash;
use dioxus::prelude::*;
use tavern::{Adventurer, Legend, Lore, Tale};
use crate::page::Page;
use crate::server::*;
const AUTHOR_CSS: Asset = asset!("/assets/css/blog_author.css");
const LIST_CSS: Asset = asset!("/assets/css/blog_list.css");
const NAV_CSS: Asset = asset!("/assets/css/blog_nav.css");
const POST_CSS: Asset = asset!("/assets/css/blog_post.css");
const TAG_CSS: Asset = asset!("/assets/css/blog_tag.css");
/// Trait to toggle the presence of a value (like a tag) in a set.
pub trait Togglable
{
type Item: ?Sized;
fn toggle(&mut self, item: &Self::Item);
fn is_toggled(&self, item: &Self::Item) -> bool;
}
impl Togglable for HashSet<String>
{
type Item = str;
fn toggle(&mut self, item: &str)
{
if self.contains(item)
{
self.remove(item);
}
else
{
self.insert(item.to_owned());
}
}
fn is_toggled(&self, item: &str) -> bool
{
self.contains(item)
}
}
fn convert_tag<S>(text: S) -> (String, String)
where S: AsRef<str>
{
if let Some((first, second)) = text.as_ref().split_once('-')
{
(first.to_owned(), second.to_owned())
}
else
{
(text.as_ref().to_owned(), "none".to_owned())
}
}
fn strip_tag(text: &str) -> &str
{
if let Some((first, _)) = text.split_once('-')
{
first
}
else
{
text
}
}
#[component]
pub fn TagItem(tag: String, style: String) -> Element
{
rsx! {
li
{
class: "tag_item",
Link
{
class: "{style}",
to: Page::Blog { tag: tag.clone() },
"{tag}"
}
// a { class: "{style}", "{tag}" }
}
}
}
#[component]
pub fn TagList(children: Element) -> Element
{
rsx! {
document::Link { rel: "stylesheet", href: TAG_CSS }
h5
{
class: "blog_tag_style",
ul
{
class: "tag_list",
{children}
}
}
}
}
#[component]
pub fn BlogAuthor() -> Element
{
rsx! {
document::Link { rel: "stylesheet", href: AUTHOR_CSS }
section
{
class: "blog_author_style",
}
}
}
#[component]
pub fn BlogItem(title: String, slug: String, author: String, summary: String,
tags: Vec<String>)
-> Element
{
let tags: Vec<(String, String)> =
tags.iter().map(|t| convert_tag(t)).collect();
rsx! {
li
{
class: "blog_item",
Link
{
to: Page::Post { slug: slug },
h1 { "{title}" }
}
TagList
{
for (tag, style) in tags
{
TagItem
{
tag: tag.clone(),
style: style.clone()
}
}
}
h4 { "Author: {author}" }
p { "{summary}" }
}
}
}
#[component]
pub fn BlogList(tags: Signal<HashSet<String>>, children: Element) -> Element
{
let list = use_server_future(move || {
let categories = tags().iter().cloned().collect();
async move { get_blog_list(categories).await }
})?;
// The tale depends on the post future resolving.
let mut summaries: Signal<Option<Vec<Lore>>> = use_signal(|| None);
use_context_provider(|| summaries);
use_effect(move || {
if let Some(Ok(data)) = &*list.read()
{
summaries.set(Some(data.clone()));
}
});
rsx! {
document::Link { rel: "stylesheet", href: LIST_CSS }
section
{
class: "blog_list_style",
ul
{
class: "blog_list",
match &*list.read()
{
Some(Ok(lores)) =>
{
rsx!
{
for lore in lores
{
BlogItem
{
title: lore.title.clone(),
slug: lore.slug.clone(),
author: lore.author.clone(),
summary: lore.summary.clone(),
tags: lore.tags.clone()
}
}
}
}
Some(Err(e)) =>
{
rsx!
{
p { "Unable to show post header." }
p { "{e}" }
}
}
None =>
{
rsx!
{
p { "Loading..." }
}
}
}
{children}
}
}
}
}
#[component]
pub fn TagSelector(toggled_tags: Signal<HashSet<String>>) -> Element
{
let mut categories = use_signal(|| Vec::<String>::new());
let tags = use_server_future(move || async move { get_tags().await })?;
use_effect(move ||
{
if let Some(Ok(ref tags_vec)) = *tags.read()
{
categories.set(tags_vec.clone());
}
});
rsx!
{
document::Link { rel: "stylesheet", href: NAV_CSS }
section
{
class: "blog_nav_style",
div
{
class: "tag_style",
h2 { "Categories" }
ul
{
class: "tag_list",
if categories.read().is_empty()
{
p { "Loading..." }
}
else
{
for tag in categories.read().iter().cloned()
{
li
{
class: "tag_item",
span
{
class: if toggled_tags.read().is_toggled(&tag)
{ "tag selected" }
else
{ "tag" },
onclick: move |_|
{ toggled_tags.write().toggle(&tag); },
"{strip_tag(&tag)}"
}
}
}
}
}
}
}
}
}
#[component]
pub fn PostHeaderAuthor(adventurer: Signal<Option<Adventurer>>) -> Element
{
rsx! {
if let Some(author) = &*adventurer.read()
{
h4
{
"Author: ",
a { href: "{author.legend.profile}", "{author.name} @{author.handle}" }
}
}
else
{
h4 { "Author: Unknown" }
}
}
}
#[component]
pub fn PostHeader(tale: Signal<Option<Tale>>,
adventurer: Signal<Option<Adventurer>>) -> Element
{
let converted_tags = use_memo(move || {
tale().as_ref()
.map(|t| {
t.lore
.tags
.iter()
.map(|tag| convert_tag(tag))
.collect::<Vec<_>>()
})
.unwrap_or_default()
});
rsx!
{
if let Some(tale) = tale()
{
h1 { {tale.lore.title} }
TagList
{
for (tag, style) in converted_tags().iter()
{
TagItem
{
tag: tag.clone(),
style: style.clone()
}
}
}
}
else
{
p { "Loading post header..." }
}
if let Some(_) = adventurer()
{
PostHeaderAuthor { adventurer: adventurer }
}
else
{
p { "Loading author..." }
}
}
}
#[component]
pub fn BlogPost(slug: Signal<String>, children: Element) -> Element
{
// The tale depends on the post future resolving.
let mut tale: Signal<Option<Tale>> = use_signal(|| None);
let mut adventurer = use_signal(|| None);
// Run a once off server fetch of the unchanging blog data.
let post = use_server_future(move ||
{
// Make this reactive so that as the page changes it should rerun this.
let url_slug = slug();
async move { get_blog_post(url_slug).await }
})?;
let author = use_server_future(move || {
let handle = match &*tale.read()
{
Some(data) => data.lore.author.to_owned(),
None => "unknown_author_handle".to_string()
};
async move { get_author(handle).await }
})?;
use_effect(move || {
if let Some(Ok(data)) = &*post.read()
{
tale.set(Some(data.clone()));
}
});
use_effect(move || {
if let Some(Ok(data)) = &*author.read()
{
adventurer.set(Some(data.clone()));
}
});
rsx! {
document::Link { rel: "stylesheet", href: POST_CSS }
article
{
class: "blog_post_style",
if let Some(Ok(post)) = &*post.read()
{
PostHeader
{
tale: tale,
adventurer: adventurer
}
//Story {}
div { dangerous_inner_html: "{post.story}" }
//Author {}
}
else if let Some(Err(e)) = &*post.read()
{
p { "Unable to load desired post: {slug.read()}" }
p { "{e}" }
}
else
{
p { "Loading..." }
}
{children}
}
}
}
#[component]
pub fn TagNav() -> Element
{
let tags = use_server_future(move || async move { get_tags().await })?;
rsx!
{
document::Link { rel: "stylesheet", href: NAV_CSS }
section
{
class: "blog_nav_style",
div
{
class: "tag_style",
h2 { "Categories" }
ul
{
class: "tag_list",
if let Some(Ok(categories)) = &*tags.read()
{
for tag in categories
{
li
{
class: "tag_item",
a { href: "/blog/{strip_tag(tag)}", "{strip_tag(tag)}" }
}
}
}
else if let Some(Err(e)) = &*tags.read()
{
p { "Unable to show desired post." }
p { "{e}" }
}
else
{
p { "Loading..." }
}
}
}
}
}
}

343
bard/src/components/list.rs Normal file
View File

@ -0,0 +1,343 @@
use std::collections::HashSet;
use dioxus::prelude::*;
use crate::togglable::Togglable;
use crate::page::Page;
use crate::server::*;
use crate::settings::{BardSettings};
use super::tags::*;
#[component]
pub fn BlogItem(title: String, slug: String, author: String, summary: String,
tags: Vec<String>)
-> Element
{
let author_future = use_server_future(move ||
{
let handle: String = author.clone();
async move { get_author(handle).await }
})?;
rsx!
{
article
{
class: "blog_item",
h2
{
Link
{
to: Page::Post { slug: slug.clone() },
"{title}"
}
}
TagList
{
for tag in tags
{
TagItem
{
tag: tag.clone(),
}
}
}
if let Some(Ok(Some(adventurer))) = (author_future.value())()
{
p
{
b
{
"Author: "
a
{
href: "{adventurer.legend.profile}", "{adventurer.name}"
}
}
}
}
else
{
p { "Loading author..." }
}
p { "{summary}" }
}
}
}
#[component]
pub fn BlogList(tags: Signal<HashSet<String>>, children: Element) -> Element
{
// Retrieve the provided settings from context.
let settings = use_context::<BardSettings>();
let list = use_resource(move ||
{
let tags = tags();
let categories: Vec<String> = tags.iter().cloned().collect();
async move { get_blog_list(categories).await }
});
rsx!
{
section
{
class: "blog_list",
match settings.blog_image
{
Some(image) =>
{
match settings.blog_name
{
Some(title) =>
{
rsx!
{
h1 { class: "blog_title visually_hidden", "{title}" }
img { class: "blog_title_image", alt: "{title} blog logo", src: image }
}
}
None =>
{
rsx!
{
h1 { class: "blog_title visually_hidden", "Blog" }
img { class: "blog_title_image", alt: "Blog logo", src: image }
}
}
}
}
None =>
{
match settings.blog_name
{
Some(title) =>
{
rsx!
{
h1 { class: "blog_title", "{title}" }
}
}
None =>
{
rsx!
{
h1 { class: "blog_title visually_hidden", "Blog" }
}
}
}
}
}
if let Some(Ok(lores)) = &*list.read()
{
for lore in lores
{
BlogItem
{
key: "{lore.slug}",
title: lore.title.clone(),
slug: lore.slug.clone(),
author: lore.author.clone(),
summary: lore.summary.clone(),
tags: lore.tags.clone()
}
}
}
else if let Some(Err(_e)) = &*list.read()
{
p { "Please choose a category to see related blog posts." }
}
else
{
p { "Loading..." }
}
{children}
}
}
}
#[component]
pub fn ToggleTag(tag: String, toggled_tags: Signal<HashSet<String>>) -> Element
{
let is_checked = toggled_tags.read().is_toggled(&tag);
rsx!
{
label
{
class: "toggle_button",
input
{
r#type: "checkbox",
checked: is_checked,
onchange: move |_|
{
toggled_tags.write().toggle(&tag.clone());
},
}
"{tag}"
}
}
}
// Using use_resource instead of use_server_future for URL-dependent tag selection
//
// While use_server_future should theoretically be reactive when reading signals in the
// closure (per Dioxus docs), in practice it doesn't reliably re-run when the url_tag
// signal changes, especially during direct URL navigation (typing URLs in browser).
//
// use_resource provides more reliable reactivity for this use case because:
// 1. It explicitly depends on url_tag and consistently re-runs when it changes
// 2. It handles both async data fetching AND selection logic in a single atomic operation
// 3. It works consistently across all navigation methods (links, direct URLs, etc.)
// 4. It avoids timing coordination issues between separate hooks
//
// This approach combines fetching available tags from the server with determining
// which tags should be selected based on the current URL, returning both pieces
// of data together for clean state management.
#[component]
pub fn TagSelector(url_tag: ReadSignal<String>,
toggled_tags: Signal<HashSet<String>>) -> Element
{
// Use use_resource to handle both fetching tags AND initializing selection
let tags_and_selection = use_resource(move ||
{
let current_url = url_tag();
async move
{
match get_tags().await
{
Ok(available_tags) =>
{
// Determine what should be selected based on URL
let should_show_all = current_url.is_empty() || current_url == "all";
let selected_tags: HashSet<String> = if should_show_all
{
available_tags.iter().cloned().collect()
}
else
{
let url_categories = convert_categories(&current_url);
let filtered: HashSet<String> = url_categories.into_iter()
.filter(|tag| available_tags.contains(tag))
.collect();
filtered
};
Ok((available_tags, selected_tags))
}
Err(e) =>
{
eprintln!("RESOURCE - Error: {}", e);
Err(e)
}
}
}
});
// Separate effect to update toggled_tags when resource completes
// This separates reading the resource from writing to toggled_tags
use_effect(move ||
{
if let Some(Ok((_, selected_tags))) = &*tags_and_selection.read()
{
toggled_tags.set(selected_tags.clone());
}
});
rsx!
{
section
{
class: "blog_nav",
h2 { class: "visually_hidden", "Filters and Navigation" }
fieldset
{
class: "tag_style",
legend { "Category Filter" }
ul
{
class: "tag_list",
match &*tags_and_selection.read()
{
Some(Ok((available_tags, _))) =>
{
rsx!
{
for tag in available_tags
{
li
{
key: "selector-{tag}",
class: "tag_item",
ToggleTag
{
tag: tag.clone(),
toggled_tags: toggled_tags
}
}
}
}
}
Some(Err(_)) =>
{
rsx!
{
li { "Error loading tags" }
}
}
None =>
{
rsx!
{
li { "Loading tags..." }
}
}
}
}
}
}
}
}
fn convert_categories(categories: &str) -> HashSet<String>
{
categories
.split('+')
.filter(|s| !s.is_empty() && *s != "all")
.map(str::to_string)
.collect()
}

View File

@ -0,0 +1,8 @@
mod list;
mod post;
mod tags;
pub use self::list::*;
pub use self::post::*;
pub use self::tags::*;

235
bard/src/components/post.rs Normal file
View File

@ -0,0 +1,235 @@
use tavern::{Adventurer, Tale};
use dioxus::prelude::*;
use crate::page::Page;
use crate::settings::BardSettings;
use crate::server::*;
use super::tags::*;
#[component]
pub fn BlogAuthor() -> Element
{
rsx!
{
section
{
class: "blog_author_style",
}
}
}
#[component]
pub fn PostHeaderAuthor(author: Option<Adventurer>, publish_date: String) -> Element
{
match author
{
Some(adventurer) =>
{
rsx!
{
document::Meta
{
name: "author",
content: "{adventurer.name}"
}
i
{
"Published: {publish_date}"
}
p
{
b
{
"Author: ",
a { href: "{adventurer.legend.profile}", "{adventurer.name}" }
}
}
}
}
None=>
{
rsx!
{
document::Meta
{
name: "author",
content: "Unknown"
}
i
{
"Published: {publish_date}"
}
p
{
b
{
"Author: Unknown"
}
}
}
}
}
}
#[component]
pub fn PostHeader(tale: Tale, adventurer: Option<Adventurer>) -> Element
{
// Get the pages URL.
let url: Page = use_route();
// Get the blog's image to use as a placeholder if there isn't one for the
// blog post.
// Retrieve the provided settings from context.
let settings = use_context::<BardSettings>();
rsx!
{
// Open Graph (used by LinkedIn, Bluesky, Discord, etc.)
document::Meta { property: "og:locale", content: "en_US" }
document::Meta { name: "title", property: "og:title", content: "{tale.lore.title}" }
document::Meta { name: "description", property: "og:description", content: "{tale.lore.summary}" }
document::Meta { property: "og:type", content: "article" }
document::Meta { property: "og:url", content: "{url}" }
if let Some(image) = settings.default_post_image
{
document::Meta { name: "image", property: "og:image", content: "{image}" }
}
h1 { {tale.lore.title} }
TagList
{
for tag in tale.lore.tags
{
TagItem
{
tag: tag.clone()
}
}
}
PostHeaderAuthor
{
author: adventurer,
publish_date: tale.lore.publish_date.format("%m/%d/%Y").to_string()
}
}
}
#[component]
pub fn Story(text: String) -> Element
{
rsx!
{
div
{
class: "blog_post_tale",
dangerous_inner_html: "{text}"
}
}
}
#[component]
pub fn BlogPost(slug: Signal<String>, children: Element) -> Element
{
// Run a once off server fetch of the unchanging blog data.
let post_future = use_server_future(move ||
{
// Make this reactive so that as the page changes it should rerun this.
let url_slug = slug();
async move
{
let post = get_blog_post(url_slug).await?;
let author = get_author(post.lore.author.clone()).await?;
Ok::<_, ServerFnError>((post, author))
}
})?;
rsx!
{
article
{
class: "blog_post",
if let Some(Ok((tale, adventurer))) = (post_future.value())()
{
PostHeader
{
tale: tale.clone(),
adventurer: adventurer.clone()
}
Story { text: tale.story }
//Author {}
}
else if let Some(Err(e)) = (post_future.value())()
{
p { "Unable to load desired post: {slug.read()}" }
p { "{e}" }
}
else
{
p { "Loading..." }
}
{children}
}
}
}
#[component]
pub fn TagNav() -> Element
{
let tags = use_server_future(move || async move { get_tags().await })?;
rsx!
{
section
{
class: "blog_nav",
fieldset
{
class: "tag_list",
legend { "Category Filter" }
if let Some(Ok(categories)) = &*tags.read()
{
TagList
{
for tag in categories
{
TagItem
{
tag: tag.clone()
}
}
}
}
else if let Some(Err(e)) = &*tags.read()
{
p { "Unable to load tags." }
p { "{e}" }
}
else
{
p { "Loading..." }
}
}
}
}
}

View File

@ -0,0 +1,46 @@
use dioxus::prelude::*;
use crate::page::Page;
#[component]
pub fn TagItem(tag: String, style: Option<String>) -> Element
{
let tag_style: String = match style
{
None => { String::new() }
Some(s) => { s }
};
rsx!
{
li
{
key: "{tag}",
class: "tag_item",
Link
{
class: "{tag_style} tag_{tag}",
to: Page::Blog { tag: tag.clone() },
"{tag}"
}
}
}
}
#[component]
pub fn TagList(children: Element) -> Element
{
rsx!
{
ul
{
class: "blog_tag tag_list",
{children}
}
}
}

View File

@ -6,6 +6,8 @@ mod components;
mod page;
mod pages;
mod server;
mod settings;
mod togglable;
@ -13,5 +15,6 @@ pub use crate::components::*;
pub use crate::info::{get_name, get_version};
pub use crate::page::Page;
pub use crate::pages::*;
pub use crate::settings::*;
#[cfg(feature = "server")]
pub use crate::server::*;

View File

@ -1,13 +1,65 @@
use dioxus::prelude::*;
use crate::pages::{Blog, Post};
use crate::pages::{Blog, Post, Root};
use crate::settings::{BardSettings, StylesheetBehavior};
const BLOG_CSS: Asset = asset!("/assets/css/blog.css");
#[component]
fn BlogLayout() -> Element
{
// Retrieve the provided settings from context.
let settings = use_context::<BardSettings>();
rsx!
{
match settings.stylesheet
{
StylesheetBehavior::Override(asset) =>
{
rsx!
{
document::Stylesheet { href: asset }
}
}
StylesheetBehavior::Extend(asset) =>
{
rsx!
{
document::Stylesheet { href: BLOG_CSS }
document::Stylesheet { href: asset }
}
}
StylesheetBehavior::None =>
{
rsx!
{
document::Stylesheet { href: BLOG_CSS }
}
}
}
Outlet::<Page> {}
}
}
//#[derive(Debug, Clone, Routable, PartialEq, Eq, Hash, Debug, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, Routable, PartialEq)]
#[rustfmt::skip]
pub enum Page
{
#[layout(BlogLayout)]
#[route("/")]
Root { },
#[route("/:tag")]
Blog { tag: String },

View File

@ -1,6 +0,0 @@
mod blog;
mod post;
pub use crate::pages::blog::Blog;
pub use crate::pages::post::Post;

View File

@ -3,43 +3,18 @@ use std::collections::HashSet;
use dioxus::prelude::*;
use crate::components::{BlogList, TagSelector};
use crate::page::Page;
const BLOG_CSS: Asset = asset!("/assets/css/blog.css");
fn convert_categories(categories: &str) -> HashSet<String>
{
if categories.is_empty() || categories == "all"
{
HashSet::new()
}
else
{
categories.split('+').map(|s| s.to_string()).collect()
}
}
/// Blog page
#[component]
pub fn Blog(tag: String) -> Element
pub fn Blog(tag: ReadSignal<String>) -> Element
{
let mut categories: Signal<HashSet<String>> =
use_signal(|| convert_categories(&tag));
let categories: Signal<HashSet<String>> =
use_signal(|| HashSet::new());
if *categories.read() != convert_categories(&tag)
rsx!
{
categories.set(convert_categories(&tag));
}
rsx! {
document::Stylesheet { href: BLOG_CSS }
main
{
class: "blog_style",
@ -54,6 +29,7 @@ pub fn Blog(tag: String) -> Element
TagSelector
{
url_tag: tag,
toggled_tags: categories
}
}

8
bard/src/pages/mod.rs Normal file
View File

@ -0,0 +1,8 @@
mod blog;
mod post;
mod root;
pub use self::blog::Blog;
pub use self::post::Post;
pub use self::root::Root;

View File

@ -1,29 +1,18 @@
use dioxus::prelude::*;
use crate::components::{BlogPost, TagNav};
use crate::page::Page;
const BLOG_CSS: Asset = asset!("/assets/css/blog.css");
/// Blog page
#[component]
pub fn Post(slug: String) -> Element
pub fn Post(slug: ReadSignal<String>) -> Element
{
// Create a copy of the current slug to detect changes.
let mut url_slug = use_signal(|| slug.clone());
let url_slug = use_signal(|| slug());
if *url_slug.read() != slug
rsx!
{
url_slug.set(slug.clone());
}
rsx! {
document::Stylesheet { href: BLOG_CSS }
main
{
class: "blog_style",

18
bard/src/pages/root.rs Normal file
View File

@ -0,0 +1,18 @@
use dioxus::prelude::*;
use crate::pages::Blog;
/// Blog page
#[component]
pub fn Root() -> Element
{
rsx!
{
Blog
{
tag: String::new()
}
}
}

View File

@ -96,13 +96,11 @@ pub async fn get_blog_post(slug: String) -> Result<Tale, ServerFnError>
#[server]
pub async fn get_author(handle: String) -> Result<Adventurer, ServerFnError>
pub async fn get_author(handle: String) -> Result<Option<Adventurer>, ServerFnError>
{
let db = get_database().await?;
let author = db.get_adventurer(&handle)
.await
.map_err(|e| ServerFnError::new(e))?;
author.ok_or(ServerFnError::new(format!("Author {} not found.", handle)))
Ok(author)
}

46
bard/src/settings.rs Normal file
View File

@ -0,0 +1,46 @@
use dioxus::prelude::*;
#[derive(Copy, Clone)]
pub enum StylesheetBehavior
{
/// Overrides the default stylesheet.
Override(Asset),
/// Extends the default stylesheet. The library will load both.
Extend(Asset),
/// Uses the library's default stylesheet.
None
}
#[derive(Clone, Default)]
pub struct BardSettings
{
/// The name to use for the blog.
/// If None, then the name will be Blog, but made invisible so that
/// it is still available for screen readers.
pub blog_name: Option<String>,
/// The image to use for the blog title instead of just the name.
pub blog_image: Option<Asset>,
/// The image to use for the default post image in open graph. This is what
/// is shown when sharing the article.
pub default_post_image: Option<Asset>,
/// A user defined stylesheet and how the library should include it.
pub stylesheet: StylesheetBehavior
}
impl Default for StylesheetBehavior
{
fn default() -> Self
{
StylesheetBehavior::None
}
}

37
bard/src/togglable.rs Normal file
View File

@ -0,0 +1,37 @@
use std::collections::HashSet;
/// Trait to toggle the presence of a value (like a tag) in a set.
pub trait Togglable
{
type Item: ?Sized;
fn toggle(&mut self, item: &Self::Item);
fn is_toggled(&self, item: &Self::Item) -> bool;
}
impl Togglable for HashSet<String>
{
type Item = str;
fn toggle(&mut self, item: &str)
{
if self.contains(item)
{
self.remove(item);
}
else
{
self.insert(item.to_owned());
}
}
fn is_toggled(&self, item: &str) -> bool
{
self.contains(item)
}
}

7
blog_test/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target
.DS_Store
# These are backup files generated by rustfmt
**/*.rs.bk

29
blog_test/Cargo.toml Normal file
View File

@ -0,0 +1,29 @@
[package]
name = "blog_test"
version = "0.3.2"
authors = ["Myrddin Dundragon <myrddin@cybermages.tech>"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
axum = { version = "0.8.4", optional = true }
axum-server = { version = "0.7.2", optional = true }
dioxus = { version = "=0.7.0-rc.1", features = ["router", "fullstack"] }
bard = { version = "*", path="../bard", optional = true }
tokio = { version = "1.0", optional = true }
[features]
default = ["bard"]
web = ["dioxus/web", "bard/web"]
server = ["dioxus/server", "axum", "axum-server", "tokio/rt-multi-thread", "tokio/macros", "bard/server"]
[profile.wasm-dev]
inherits = "dev"
opt-level = 1
[profile.server-dev]
inherits = "dev"
[profile.android-dev]
inherits = "dev"

21
blog_test/Dioxus.toml Normal file
View File

@ -0,0 +1,21 @@
[application]
[web.app]
# HTML title tag content
title = "blog_test"
# include `assets` in web platform
[web.resource]
# Additional CSS style files
style = []
# Additional JavaScript files
script = []
[web.resource.dev]
# Javascript code file
# serve: [dev-server] only
script = []

25
blog_test/README.md Normal file
View File

@ -0,0 +1,25 @@
# Development
Your new bare-bones project includes minimal organization with a single `main.rs` file and a few assets.
```
project/
├─ assets/ # Any assets that are used by the app should be placed here
├─ src/
│ ├─ main.rs # main.rs is the entry point to your application and currently contains all components for the app
├─ Cargo.toml # The Cargo.toml file defines the dependencies and feature flags for your project
```
### Serving Your App
Run the following command in the root of your project to start developing with the default platform:
```bash
dx serve --platform web
```
To run for a different platform, use the `--platform platform` flag. E.g.
```bash
dx serve --platform desktop
```

45
blog_test/assets/blog.css Normal file
View File

@ -0,0 +1,45 @@
:root
{
--text-color: #000000;
--bg-color: #FFFFFF;
--primary-color: #ffffff;
--secondary-color: #000000;
--accent-color: #7dfdfe; /* Tron grid or try 00a8ff 00eaff 26b4ca 59b4c7 5584AC*/
--mobile-color: #363636;
--desktop-size: 1230px;
}
.toggle_button {
display: inline-block;
margin-top: 5px;
margin-right: 5px;
padding: 0px 6px;
border: 2px solid var(--text-color);
border-radius: 5px;
color: var(--text-color);
cursor: pointer;
user-select: none;
transition: 0.1s ease-in;
}
.toggle_button:hover {
color: var(--accent-color);
border-color: var(--accent-color);
transform: translateY(-2px);
}
.toggle_button input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
/* Checked state using :has() — modern browsers only */
.toggle_button:has(input:checked) {
color: var(--accent-color);
border-color: var(--accent-color);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

107
blog_test/assets/main.css Normal file
View File

@ -0,0 +1,107 @@
/* App-wide styling */
body {
background-color: #0f1116;
color: #ffffff;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 20px;
}
#hero {
margin: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
#links {
width: 400px;
text-align: left;
font-size: x-large;
color: white;
display: flex;
flex-direction: column;
}
#links a {
color: white;
text-decoration: none;
margin-top: 20px;
margin: 10px 0px;
border: white 1px solid;
border-radius: 5px;
padding: 10px;
}
#links a:hover {
background-color: #1f1f1f;
cursor: pointer;
}
#header {
max-width: 1200px;
}
/* Navbar */
#navbar {
display: flex;
flex-direction: row;
}
#navbar a {
color: #ffffff;
margin-right: 20px;
text-decoration: none;
transition: color 0.2s ease;
}
#navbar a:hover {
cursor: pointer;
color: #91a4d2;
}
/* Blog page */
#blog {
margin-top: 50px;
}
#blog a {
color: #ffffff;
margin-top: 50px;
}
/* Echo */
#echo {
width: 360px;
margin-left: auto;
margin-right: auto;
margin-top: 50px;
background-color: #1e222d;
padding: 20px;
border-radius: 10px;
}
#echo>h4 {
margin: 0px 0px 15px 0px;
}
#echo>input {
border: none;
border-bottom: 1px white solid;
background-color: transparent;
color: #ffffff;
transition: border-bottom-color 0.2s ease;
outline: none;
display: block;
padding: 0px 0px 5px 0px;
width: 100%;
}
#echo>input:focus {
border-bottom-color: #6d85c6;
}
#echo>p {
margin: 20px 0px 0px auto;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1019 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

32
blog_test/blog.css Normal file
View File

@ -0,0 +1,32 @@
.toggle_button {
display: inline-block;
margin-top: 5px;
margin-right: 5px;
padding: 0px 6px;
border: 2px solid var(--text-color);
border-radius: 5px;
color: var(--text-color);
cursor: pointer;
user-select: none;
transition: 0.1s ease-in;
}
.toggle_button:hover {
color: var(--accent-color);
border-color: var(--accent-color);
transform: translateY(-2px);
}
.toggle_button input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
/* Checked state using :has() — modern browsers only */
.toggle_button:has(input:checked) {
color: var(--accent-color);
border-color: var(--accent-color);
}

8
blog_test/clippy.toml Normal file
View File

@ -0,0 +1,8 @@
await-holding-invalid-types = [
"generational_box::GenerationalRef",
{ path = "generational_box::GenerationalRef", reason = "Reads should not be held over an await point. This will cause any writes to fail while the await is pending since the read borrow is still active." },
"generational_box::GenerationalRefMut",
{ path = "generational_box::GenerationalRefMut", reason = "Write should not be held over an await point. This will cause any reads or writes to fail while the await is pending since the write borrow is still active." },
"dioxus_signals::Write",
{ path = "dioxus_signals::Write", reason = "Write should not be held over an await point. This will cause any reads or writes to fail while the await is pending since the write borrow is still active." },
]

111
blog_test/src/main.rs Normal file
View File

@ -0,0 +1,111 @@
use dioxus::prelude::*;
use bard::*;
const FAVICON: Asset = asset!("/assets/favicon.ico");
const BLOG: Asset = asset!("/assets/blog.css");
const BLOG_IMAGE: Asset = asset!("/assets/runes_and_ramblings_text.png",
AssetOptions::builder().with_hash_suffix(false));
const POST_IMAGE: Asset = asset!("/assets/runes_and_ramblings_logo.png",
AssetOptions::builder().with_hash_suffix(false));
fn main()
{
#[cfg(feature = "server")]
{
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async
{
let db_path = "/home/myrddin/cybermages/website/tavern.db";
let _ = bard::init_database(db_path).await;
});
}
LaunchBuilder::new().launch(App);
}
#[component]
fn App() -> Element
{
let custom_settings = BardSettings
{
blog_name: Some(String::from("Blog Test")),
blog_image: Some(BLOG_IMAGE),
default_post_image: Some(POST_IMAGE),
stylesheet: StylesheetBehavior::Extend(BLOG),
};
provide_context(custom_settings);
rsx!
{
document::Link { rel: "icon", href: FAVICON }
Router::<Page> {}
}
}
/// Home page
#[component]
fn Home() -> Element
{
rsx! {
h1 { "Blog Test" }
}
}
/// This is the content for the About page.
#[component]
pub fn PageNotFound(route: Vec<String>) -> Element
{
rsx! {
h1 { "Page not found" }
p { "We are terribly sorry, but the page you requested doesn't exist." }
pre { color: "red", "log:\nattemped to navigate to: {route:?}" }
}
}
/// Shared navbar component.
#[component]
fn Navbar() -> Element
{
rsx! {
div {
id: "navbar",
Link {
to: Page::Home {},
"Home"
}
Link {
to: Page::Bard{ child: bard::Page::Blog { tag: String::from("all") }},
"Blog"
}
//a { href: "/blog/all", "Blog" }
}
Outlet::<Page> {}
}
}
#[derive(Debug, Clone, Routable, PartialEq)]
#[rustfmt::skip]
pub enum Page
{
#[layout(Navbar)]
#[route("/")]
Home {},
#[child("/blog")]
Bard
{
child: bard::Page
},
#[route("/:..route")]
PageNotFound { route: Vec<String> }
}

View File

@ -1,18 +0,0 @@
# Loremaster
Converts a blog repository into an SQLite database using the Tavern blog system.
---
## 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

@ -1,18 +0,0 @@
//! Converts a blog repository into an SQLite database using the Tavern blog system.
mod info;
/// Print the version.
fn print_version()
{
println!("{} v{}", info::get_name(), info::get_version());
}
fn main()
{
print_version();
}

View File

@ -1,6 +1,6 @@
[package]
name = "loremaster"
version = "0.1.0"
name = "loreweaver"
version = "0.3.2"
edition = "2024"
description = "Converts a blog repository into an SQLite database using the Tavern blog system."
repository = "/CyberMages/tavern"
@ -10,3 +10,6 @@ readme = "README.md"
license = "Apache-2.0"
[dependencies]
clap = { version = "4.5.46", features = ["derive"] }
tavern = { version = "0.3.0", path = "../tavern", registry = "cybermages", features = ["publisher"] }
tokio = { version = "1.47.1", features = ["rt-multi-thread", "macros"] }

42
loreweaver/README.md Normal file
View File

@ -0,0 +1,42 @@
# Loreweaver
Loreweaver is a command-line tool that compiles a blog repository into a SQLite
database using the Tavernworks blog system. It acts as a publisher that reads
a metadata configuration file and processes the blog content via the `tavern`
engine.
## Features
- Reads a `Tavern.toml` metadata file to configure the compilation
- Converts Markdown blog repositories into portable SQLite databases
- Simple CLI interface with configurable input and output paths
- Built on top of the `tavern` library for content parsing and data handling
## Usage
Run Loreweaver with the default configuration file and output:
```bash
loreweaver --config Tavern.toml --output tavern.db
```
Specify custom paths for config or output files:
```bash
loreweaver --config path/to/config.toml --output path/to/output.db
```
---
## 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

View File

@ -3,12 +3,15 @@
/// The environment variable defined by Cargo for the name.
#[allow(dead_code)]
const NAME: Option<&str> = option_env!("CARGO_PKG_NAME");
/// The environment variable defined by Cargo for the version.
#[allow(dead_code)]
const VERSION: Option<&str> = option_env!("CARGO_PKG_VERSION");
/// The string to display if a value is not defined during compile time.
#[allow(dead_code)]
const NOT_DEFINED: &'static str = "UNDEFINED";
@ -17,6 +20,7 @@ const NOT_DEFINED: &'static str = "UNDEFINED";
/// 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.
#[allow(dead_code)]
pub fn get_name() -> &'static str
{
NAME.unwrap_or(NOT_DEFINED)
@ -27,6 +31,7 @@ pub fn get_name() -> &'static str
/// 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.
#[allow(dead_code)]
pub fn get_version() -> &'static str
{
VERSION.unwrap_or(NOT_DEFINED)

37
loreweaver/src/main.rs Normal file
View File

@ -0,0 +1,37 @@
//! Converts a blog repository into an SQLite database using the Tavern blog system.
mod info;
use clap::Parser;
use tavern::{Database, Tavern};
#[derive(Parser)]
#[command(version, about)]
struct Options
{
#[arg(short = 'c', long = "config", default_value = "Tavern.toml")]
config_file: std::path::PathBuf,
#[arg(short = 'o', long = "output", default_value = "tavern.db")]
output: std::path::PathBuf
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>>
{
let options = Options::parse();
let tavern: Tavern = Tavern::from_config_file(&options.config_file);
let database = Database::open(&options.output).await?;
database.insert_tavern(&tavern).await?;
Ok(())
}

2
rust-toolchain.toml Normal file
View File

@ -0,0 +1,2 @@
[toolchain]
channel = "nightly"

View File

@ -1,6 +1,6 @@
[package]
name = "tavern"
version = "0.2.9"
version = "0.3.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"

View File

@ -1,7 +1,28 @@
# Tavern
A blogging system that will allow you to write your blog in Markdown and then
display it in HTML using Dioxus.
Tavern is the core content engine of the Tavernworks blog system.
It provides the data model, parsing logic, and database interaction necessary
to convert a Markdown-based blog repository into a structured, queryable form.
## Features
- Parses blog content from Markdown files and metadata.
- Converts content into an in-memory model or stores it in SQLite.
- Provides an async API to query and manipulate blog data.
- Designed to be extensible and integrated into CLI tools or frontends.
## Usage
As a library, `tavern` can be embedded in Rust applications or CLI tools
to handle blog content processing and storage.
```rust
use tavern::{Tavern, Database};
let tavern = Tavern::from_config_file("Tavern.toml");
let database = Database::open("blog.db").await?;
database.insert_tavern(&tavern).await?;
```
---

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

View File

@ -304,7 +304,8 @@ impl Database
// Start a read-only transaction.
let mut tx = self.pool.begin().await?;
// Dynamically build the query.
// Build the base query string with placeholders for categories in the EXISTS clause.
// We add a parameter for categories length to check if filtering is needed.
let mut query = String::from(
"SELECT
t.title,
@ -312,51 +313,62 @@ impl Database
t.summary,
t.author,
t.publish_date,
GROUP_CONCAT(tg.name, ',') AS tags
GROUP_CONCAT(tg_all.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"
LEFT JOIN tale_tags AS tt_all ON t.slug = tt_all.tale_slug
LEFT JOIN tags AS tg_all ON tt_all.tag_id = tg_all.id
WHERE (? = 0 OR EXISTS (
SELECT 1 FROM tale_tags tt_filter
JOIN tags tg_filter ON tt_filter.tag_id = tg_filter.id
WHERE tt_filter.tale_slug = t.slug
AND tg_filter.name IN ("
);
if !categories.is_empty()
{
query.push_str(" WHERE tg.name IN (");
let placeholders: Vec<_> =
(0..categories.len()).map(|_| "?").collect();
// Add placeholders for category names in EXISTS IN clause
if !categories.is_empty() {
let placeholders: Vec<_> = (0..categories.len()).map(|_| "?").collect();
query.push_str(&placeholders.join(", "));
query.push(')');
} else {
// No categories, so dummy placeholder to satisfy SQL syntax
query.push_str("NULL");
}
query.push_str("))) GROUP BY t.slug ORDER BY t.publish_date DESC");
query.push_str(" GROUP BY t.slug ORDER BY t.publish_date DESC");
// Prepare query with sqlx
let mut q = sqlx::query(&query);
for cat in categories
{
// Bind the length of categories for the (? = 0) check
q = q.bind(categories.len() as i64);
// Bind the category names if any
for cat in categories {
q = q.bind(cat);
}
// Execute the query
let rows = q.fetch_all(&mut *tx).await?;
let current_time = chrono::Utc::now().naive_utc();
for row in rows
{
for row in rows {
let date_str: String = row.try_get("publish_date")?;
let publish_date = chrono::NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%d %H:%M:%S")
.map_err(|e| sqlx::Error::Decode(e.into()))?;
// Only give tales that are ready to be published.
if current_time >= publish_date
{
// Only include tales that are ready to be published.
if current_time >= publish_date {
let tags_str: Option<String> = row.try_get("tags")?;
let tags = tags_str.map(|s| s.split(',').map(String::from).collect())
let tags = tags_str
.map(|s| s.split(',').map(String::from).collect())
.unwrap_or_default();
tales.push(Lore { title: row.try_get("title")?,
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 });
tags,
});
}
}