From b041941a4a47cf96195d6c7dd0506d112df5bbac Mon Sep 17 00:00:00 2001 From: lew Date: Thu, 9 Apr 2026 17:11:59 +0100 Subject: [PATCH] classes on all elements, configurable form labels/style/textarea --- .env.example | 8 ++ module.nix | 56 +++++++++++++ src/config.rs | 27 +++++++ src/render.rs | 173 +++++++++++++++++++++++++++++++---------- src/web.rs | 21 +++-- templates/default.html | 1 + 6 files changed, 240 insertions(+), 46 deletions(-) diff --git a/.env.example b/.env.example index 28e5594..2013aa5 100644 --- a/.env.example +++ b/.env.example @@ -10,4 +10,12 @@ BOOK_MAX_MESSAGE_LENGTH=1000 BOOK_MAX_WEBSITE_LENGTH=100 BOOK_OPEN_REGISTRATION=true BOOK_SEPARATOR=------------------------------------------------------------ +BOOK_FORM_PROMPT=If you visited my site, please sign my guestbook! +BOOK_BUTTON_TEXT=sign +BOOK_LABEL_NAME=Your name: +BOOK_LABEL_WEBSITE=Your website (optional): +BOOK_LABEL_MESSAGE=Your message: +BOOK_TEXTAREA_ROWS=8 +BOOK_TEXTAREA_COLS=60 +# BOOK_STYLE=.entry-name { font-weight: bold; } # BOOK_TEMPLATE=./templates/default.html diff --git a/module.nix b/module.nix index 577bbf6..6464d15 100644 --- a/module.nix +++ b/module.nix @@ -81,6 +81,54 @@ in description = "Separator between guestbook entries."; }; + style = mkOption { + type = types.str; + default = ""; + description = "Custom CSS injected into a style tag. Use class names: .guestbook-form, .guestbook-prompt, .guestbook-label, .guestbook-input, .guestbook-textarea, .guestbook-button, .entry-header, .entry-name, .entry-website, .entry-body, .entry-separator"; + }; + + formPrompt = mkOption { + type = types.str; + default = "If you visited my site, please sign my guestbook!"; + description = "Text shown above the form."; + }; + + buttonText = mkOption { + type = types.str; + default = "sign"; + description = "Submit button text."; + }; + + labelName = mkOption { + type = types.str; + default = "Your name:"; + description = "Label for the name field."; + }; + + labelWebsite = mkOption { + type = types.str; + default = "Your website (optional):"; + description = "Label for the website field."; + }; + + labelMessage = mkOption { + type = types.str; + default = "Your message:"; + description = "Label for the message field."; + }; + + textareaRows = mkOption { + type = types.int; + default = 8; + description = "Number of rows for the message textarea."; + }; + + textareaCols = mkOption { + type = types.int; + default = 60; + description = "Number of columns for the message textarea."; + }; + templateFile = mkOption { type = types.nullOr types.path; default = null; @@ -128,6 +176,14 @@ in BOOK_MAX_WEBSITE_LENGTH = toString cfg.maxWebsiteLength; BOOK_OPEN_REGISTRATION = if cfg.openRegistration then "true" else "false"; BOOK_SEPARATOR = cfg.separator; + BOOK_STYLE = cfg.style; + BOOK_FORM_PROMPT = cfg.formPrompt; + BOOK_BUTTON_TEXT = cfg.buttonText; + BOOK_LABEL_NAME = cfg.labelName; + BOOK_LABEL_WEBSITE = cfg.labelWebsite; + BOOK_LABEL_MESSAGE = cfg.labelMessage; + BOOK_TEXTAREA_ROWS = toString cfg.textareaRows; + BOOK_TEXTAREA_COLS = toString cfg.textareaCols; } // lib.optionalAttrs (cfg.templateFile != null) { BOOK_TEMPLATE = cfg.templateFile; }; diff --git a/src/config.rs b/src/config.rs index af4798f..b552b1e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -16,6 +16,14 @@ pub struct Config { pub open_registration: bool, pub template: Option, pub separator: String, + pub style: String, + pub form_prompt: String, + pub button_text: String, + pub label_name: String, + pub label_website: String, + pub label_message: String, + pub textarea_rows: u32, + pub textarea_cols: u32, } impl Config { @@ -64,6 +72,25 @@ impl Config { std::fs::read_to_string(&path) .unwrap_or_else(|e| panic!("failed to read template {path}: {e}")) }), + style: env::var("BOOK_STYLE").unwrap_or_default(), + form_prompt: env::var("BOOK_FORM_PROMPT") + .unwrap_or_else(|_| "If you visited my site, please sign my guestbook!".into()), + button_text: env::var("BOOK_BUTTON_TEXT") + .unwrap_or_else(|_| "sign".into()), + label_name: env::var("BOOK_LABEL_NAME") + .unwrap_or_else(|_| "Your name:".into()), + label_website: env::var("BOOK_LABEL_WEBSITE") + .unwrap_or_else(|_| "Your website (optional):".into()), + label_message: env::var("BOOK_LABEL_MESSAGE") + .unwrap_or_else(|_| "Your message:".into()), + textarea_rows: env::var("BOOK_TEXTAREA_ROWS") + .unwrap_or_else(|_| "8".into()) + .parse() + .map_err(|_| "BOOK_TEXTAREA_ROWS must be a number")?, + textarea_cols: env::var("BOOK_TEXTAREA_COLS") + .unwrap_or_else(|_| "60".into()) + .parse() + .map_err(|_| "BOOK_TEXTAREA_COLS must be a number")?, }) } } diff --git a/src/render.rs b/src/render.rs index a55bd10..2b3ec1b 100644 --- a/src/render.rs +++ b/src/render.rs @@ -1,3 +1,4 @@ +use crate::config::Config; use crate::entries::Entry; pub const DEFAULT_TEMPLATE: &str = r#" @@ -10,13 +11,13 @@ pub const DEFAULT_TEMPLATE: &str = r#" pre { font: unset; max-width: 70ch; - margin: 0 auto; padding: 1rem; white-space: pre-wrap; word-wrap: break-word; } + {{style}}
@@ -35,12 +36,43 @@ entries
 
 "#;
 
-pub fn render_page(template: &str, title: &str, entries: &[Entry], form_html: &str, separator: &str) -> String {
-    let entries_html = render_entries(entries, separator);
+pub fn render_page(template: &str, config: &Config, entries: &[Entry], form_html: &str) -> String {
+    let entries_html = render_entries(entries, &config.separator);
+    let style = if config.style.is_empty() {
+        String::new()
+    } else {
+        format!("", config.style)
+    };
     template
-        .replace("{{title}}", title)
+        .replace("{{title}}", &config.site_title)
         .replace("{{form}}", form_html)
         .replace("{{entries}}", &entries_html)
+        .replace("{{style}}", &style)
+}
+
+pub fn render_form(config: &Config) -> String {
+    format!(
+        r#"{prompt}
+
+ + + + + + + + + + +
"#, + prompt = config.form_prompt, + label_name = config.label_name, + label_website = config.label_website, + label_message = config.label_message, + rows = config.textarea_rows, + cols = config.textarea_cols, + button = config.button_text, + ) } fn render_entries(entries: &[Entry], separator: &str) -> String { @@ -52,37 +84,54 @@ fn render_entries(entries: &[Entry], separator: &str) -> String { } fn render_entry(entry: &Entry, separator: &str) -> String { - let mut header = format!("{} - {}", entry.meta.date, entry.meta.name); + let mut header = format!( + "{} - {}", + entry.meta.date, entry.meta.name + ); if !entry.meta.website.is_empty() { header.push_str(&format!( - " ({})", + " ({})", entry.meta.website, entry.meta.website )); } + header.push_str(""); format!( - "\n{header}\n\n{}\n\n{separator}\n", + "\n{header}\n\n{}\n\n{separator}\n", entry.body ) } -pub const FORM_HTML: &str = r#"If you visited my site, please sign my guestbook! -
-Your name: - - -Your website (optional): - - -Your message: - - - -
"#; - #[cfg(test)] mod tests { use super::*; use crate::entries::{Entry, EntryMeta, Status}; + use std::path::PathBuf; + + fn test_config() -> Config { + Config { + port: 0, + data_dir: PathBuf::from("./data"), + site_title: "test".into(), + site_url: "https://test.rs".into(), + telegram_bot_token: "fake".into(), + telegram_chat_id: 0, + honeypot: true, + max_name_length: 50, + max_message_length: 1000, + max_website_length: 100, + open_registration: true, + template: None, + separator: "---".into(), + style: String::new(), + form_prompt: "Sign my guestbook!".into(), + button_text: "sign".into(), + label_name: "Your name:".into(), + label_website: "Your website (optional):".into(), + label_message: "Your message:".into(), + textarea_rows: 8, + textarea_cols: 60, + } + } fn make_entry(name: &str, date: &str, body: &str) -> Entry { Entry { @@ -99,48 +148,90 @@ mod tests { #[test] 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\"")); - assert!(html.contains("
"));
+        let config = test_config();
+        let form = render_form(&config);
+        let html = render_page(DEFAULT_TEMPLATE, &config, &[], &form);
+        assert!(html.contains("test"));
+        assert!(html.contains("guestbook-form"));
     }
 
     #[test]
     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\""));
+        let config = test_config();
+        let custom = "{{title}} {{form}} {{entries}} {{style}}";
+        let form = render_form(&config);
+        let html = render_page(custom, &config, &[], &form);
+        assert!(html.contains("test"));
+        assert!(html.contains("guestbook-form"));
     }
 
     #[test]
-    fn test_render_entry_no_website() {
+    fn test_render_entry_classes() {
+        let config = test_config();
         let entry = make_entry("alice", "2026-04-09", "Hello!");
-        let html = render_page(DEFAULT_TEMPLATE, "test", &[entry], FORM_HTML, "---");
-        assert!(html.contains("2026-04-09 - alice"));
-        assert!(html.contains("Hello!"));
-        assert!(html.contains("---"));
+        let form = render_form(&config);
+        let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form);
+        assert!(html.contains("entry-header"));
+        assert!(html.contains("entry-name"));
+        assert!(html.contains("entry-body"));
+        assert!(html.contains("entry-separator"));
     }
 
     #[test]
     fn test_render_entry_with_website() {
+        let config = test_config();
         let mut entry = make_entry("bob", "2026-04-09", "Hi!");
         entry.meta.website = "https://bob.com".into();
-        let html = render_page(DEFAULT_TEMPLATE, "test", &[entry], FORM_HTML, "---");
-        assert!(html.contains(r#""#));
+        let form = render_form(&config);
+        let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form);
+        assert!(html.contains("entry-website"));
+        assert!(html.contains(r#"href="https://bob.com">"#));
     }
 
     #[test]
     fn test_render_preserves_html_in_body() {
-        let entry = make_entry("carol", "2026-04-09", "Bold ");
-        let html = render_page(DEFAULT_TEMPLATE, "test", &[entry], FORM_HTML, "---");
+        let config = test_config();
+        let entry = make_entry("carol", "2026-04-09", "Bold");
+        let form = render_form(&config);
+        let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form);
         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\""));
+        let config = test_config();
+        let html = render_page(DEFAULT_TEMPLATE, &config, &[], "");
+        assert!(!html.contains("guestbook-form"));
+    }
+
+    #[test]
+    fn test_render_custom_style() {
+        let mut config = test_config();
+        config.style = ".entry-name { color: red; }".into();
+        let html = render_page(DEFAULT_TEMPLATE, &config, &[], "");
+        assert!(html.contains(".entry-name { color: red; }"));
+        assert!(html.contains("
+  {{style}}