diff --git a/module.nix b/module.nix index 7326c7e..f6409fd 100644 --- a/module.nix +++ b/module.nix @@ -75,6 +75,12 @@ in description = "Allow new guestbook submissions. When false, the form is hidden and submissions are rejected."; }; + templateFile = mkOption { + type = types.nullOr types.path; + default = null; + description = "Custom HTML template file with {{title}}, {{form}}, and {{entries}} placeholders. Uses built-in default if null."; + }; + user = mkOption { type = types.str; default = "guestbook"; @@ -115,6 +121,8 @@ in BOOK_MAX_MESSAGE_LENGTH = toString cfg.maxMessageLength; BOOK_MAX_WEBSITE_LENGTH = toString cfg.maxWebsiteLength; BOOK_OPEN_REGISTRATION = if cfg.openRegistration then "true" else "false"; + } // lib.optionalAttrs (cfg.templateFile != null) { + BOOK_TEMPLATE = cfg.templateFile; }; serviceConfig = { Type = "simple"; diff --git a/src/config.rs b/src/config.rs index 84a1128..769d263 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,6 +14,7 @@ pub struct Config { pub max_message_length: usize, pub max_website_length: usize, pub open_registration: bool, + pub template: Option, } impl Config { @@ -56,6 +57,10 @@ impl Config { open_registration: env::var("BOOK_OPEN_REGISTRATION") .map(|v| v != "false") .unwrap_or(true), + template: env::var("BOOK_TEMPLATE").ok().map(|path| { + std::fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("failed to read template {path}: {e}")) + }), }) } } diff --git a/src/render.rs b/src/render.rs index 68f6fab..cbcf3fa 100644 --- a/src/render.rs +++ b/src/render.rs @@ -1,39 +1,49 @@ use crate::entries::Entry; -pub fn render_page(site_title: &str, site_url: &str, entries: &[Entry], form_html: &str) -> String { - let nav_url = site_url.trim_end_matches('/'); - let mut html = format!( - r#" +pub const DEFAULT_TEMPLATE: &str = r#" - {site_title} - + {{title}} + -

guestbook

-

If you visited my site, please sign my guestbook!

-{form_html} -"# - ); + {{form}} + {{entries}} + + +"#; +pub fn render_page(template: &str, title: &str, entries: &[Entry], form_html: &str) -> String { + let entries_html = render_entries(entries); + template + .replace("{{title}}", title) + .replace("{{form}}", form_html) + .replace("{{entries}}", &entries_html) +} + +fn render_entries(entries: &[Entry]) -> String { + let mut html = String::new(); for entry in entries { html.push_str(&render_entry(entry)); } - - html.push_str("\n\n"); html } fn render_entry(entry: &Entry) -> String { - let mut header = format!("
\n

{} - {}", entry.meta.date, entry.meta.name); + let mut header = format!( + "

\n

{} - {}", + entry.meta.date, entry.meta.name + ); if !entry.meta.website.is_empty() { header.push_str(&format!( " ({})", @@ -52,14 +62,6 @@ pub const FORM_HTML: &str = r#"

"#; -pub const STYLE_CSS: &str = "body { - max-width: 70ch; - line-height: 1.5; - margin: 0 auto; - padding: 1rem; -} -"; - #[cfg(test)] mod tests { use super::*; @@ -79,23 +81,24 @@ mod tests { } #[test] - fn test_render_page_contains_nav() { - let html = render_page("ily.rs", "https://ily.rs", &[], FORM_HTML); - assert!(html.contains(r#"ily.rs"#)); - assert!(html.contains(r#"links"#)); + fn test_render_default_template() { + let html = render_page(DEFAULT_TEMPLATE, "ily.rs", &[], FORM_HTML); + assert!(html.contains("ily.rs")); + assert!(html.contains("action=\"/submit\"")); } #[test] - fn test_render_page_contains_form() { - let html = render_page("ily.rs", "https://ily.rs", &[], FORM_HTML); - assert!(html.contains(r#"action="/submit""#)); - assert!(html.contains(r#"style="display:none""#)); // honeypot + fn test_render_custom_template() { + let custom = "{{title}} {{form}} {{entries}}"; + let html = render_page(custom, "my site", &[], FORM_HTML); + assert!(html.contains("my site")); + assert!(html.contains("action=\"/submit\"")); } #[test] fn test_render_entry_no_website() { let entry = make_entry("alice", "2026-04-09", "Hello!"); - let html = render_page("ily.rs", "https://ily.rs", &[entry], FORM_HTML); + let html = render_page(DEFAULT_TEMPLATE, "test", &[entry], FORM_HTML); assert!(html.contains("alice")); assert!(html.contains("Hello!")); assert!(!html.contains(""#)); } #[test] fn test_render_preserves_html_in_body() { let entry = make_entry("carol", "2026-04-09", "Bold "); - let html = render_page("ily.rs", "https://ily.rs", &[entry], FORM_HTML); + let html = render_page(DEFAULT_TEMPLATE, "test", &[entry], FORM_HTML); assert!(html.contains("Bold")); assert!(html.contains("")); } + + #[test] + fn test_render_empty_form_when_closed() { + let html = render_page(DEFAULT_TEMPLATE, "test", &[], ""); + assert!(!html.contains("action=\"/submit\"")); + } } diff --git a/src/web.rs b/src/web.rs index 4e7b21c..5d2e92b 100644 --- a/src/web.rs +++ b/src/web.rs @@ -1,7 +1,6 @@ use axum::{ extract::State, - http::header, - response::{Html, IntoResponse}, + response::Html, routing::{get, post}, Form, Router, }; @@ -11,7 +10,7 @@ use uuid::Uuid; use crate::config::Config; use crate::entries::{self, Entry, EntryMeta, Status}; -use crate::render::{self, FORM_HTML, STYLE_CSS}; +use crate::render::{self, DEFAULT_TEMPLATE, FORM_HTML}; pub struct AppState { pub config: Config, @@ -32,7 +31,6 @@ pub fn router(state: Arc) -> Router { Router::new() .route("/", get(index)) .route("/submit", post(submit)) - .route("/style.css", get(style)) .with_state(state) } @@ -40,9 +38,10 @@ async fn index(State(state): State>) -> Html { let entries_dir = state.config.data_dir.join("entries"); let entries = entries::read_approved(&entries_dir); let form = if state.config.open_registration { FORM_HTML } else { "" }; + let template = state.config.template.as_deref().unwrap_or(DEFAULT_TEMPLATE); let html = render::render_page( + template, &state.config.site_title, - &state.config.site_url, &entries, form, ); @@ -113,10 +112,6 @@ async fn submit( Html("Thanks! Your message is pending approval.".to_string()) } -async fn style() -> impl IntoResponse { - ([(header::CONTENT_TYPE, "text/css")], STYLE_CSS) -} - #[cfg(test)] mod tests { use super::*; @@ -138,6 +133,7 @@ mod tests { max_message_length: 1000, max_website_length: 100, open_registration: true, + template: None, } } @@ -270,6 +266,17 @@ mod tests { assert!(body.contains("too long")); } + #[tokio::test] + async fn test_custom_template() { + let dir = tempfile::tempdir().unwrap(); + let mut config = test_config(dir.path()); + config.template = Some("{{form}}{{entries}}".into()); + let (app, _rx) = test_app(config); + let html = get_index(&app).await; + assert!(html.contains("custom nav")); + assert!(html.contains("action=\"/submit\"")); + } + #[tokio::test] async fn test_valid_submission_creates_entry() { let dir = tempfile::tempdir().unwrap();