use crate::config::Config; use crate::entries::Entry; pub const DEFAULT_TEMPLATE: &str = include_str!("../templates/default.html"); pub const DEFAULT_SUCCESS_TEMPLATE: &str = include_str!("../templates/success.html"); pub const DEFAULT_STYLE: &str = include_str!("../templates/default.css"); pub fn render_page(template: &str, config: &Config, entries: &[Entry], form_html: &str) -> String { let entries_html = render_entries(entries, config); let css = if config.style.is_empty() { DEFAULT_STYLE } else { &config.style }; let style = format!(""); let prompt = if config.enable_submissions { format!( "{}", config.form_prompt ) } else { String::new() }; template .replace("{{title}}", &config.site_title) .replace("{{prompt}}", &prompt) .replace("{{form}}", form_html) .replace("{{entries}}", &entries_html) .replace("{{style}}", &style) } pub fn render_form(config: &Config) -> String { let website_section = if config.enable_website_links { format!( "\n\n\n", config.label_website ) } else { String::new() }; let captcha_section = if config.enable_captcha { format!( "\n\n\n", config.captcha_question ) } else { String::new() }; let drawing_section = if config.enable_drawings { format!( r##"add a drawing "##, w = config.canvas_width, h = config.canvas_height, ) } else { String::new() }; let voice_note_section = if config.enable_voice_notes { format!( r##"add a voice note "##, max_dur = config.voice_note_max_duration, ) } else { String::new() }; format!( r#"
{website_section} {captcha_section} {drawing_section}{voice_note_section}
"#, label_name = config.label_name, website_section = website_section, label_message = config.label_message, tw = config.textarea_width, th = config.textarea_height, captcha_section = captcha_section, drawing_section = drawing_section, voice_note_section = voice_note_section, button = config.button_text, ) } pub fn render_success_page(config: &Config) -> String { let template = config.success_template.as_deref().unwrap_or(DEFAULT_SUCCESS_TEMPLATE); let css = if config.style.is_empty() { DEFAULT_STYLE } else { &config.style }; let style = format!(""); template .replace("{{title}}", &config.site_title) .replace("{{style}}", &style) } pub fn render_error_page(config: &Config, error: &str) -> String { let css = if config.style.is_empty() { DEFAULT_STYLE } else { &config.style }; let error = escape_html(error); format!( r#" {title}
{error} ← back
"#, title = config.site_title, ) } fn escape_html(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } fn render_entries(entries: &[Entry], config: &Config) -> String { let mut html = String::new(); for entry in entries { html.push_str(&render_entry(entry, config)); } html } fn render_entry(entry: &Entry, config: &Config) -> String { let name = if config.enable_html_injection { entry.meta.name.clone() } else { escape_html(&entry.meta.name) }; let mut header = format!( "{} - {}", &entry.meta.date[..10], name ); if config.enable_website_links && !entry.meta.website.is_empty() { let website = escape_html(&entry.meta.website); header.push_str(&format!( " ({})", website, website )); } header.push_str(""); let body = if config.enable_html_injection { entry.body.clone() } else { escape_html(&entry.body) }; let drawing_html = if !entry.meta.drawing.is_empty() { format!( "\"Drawing", escape_html(&entry.meta.drawing), escape_html(&entry.meta.name) ) } else { String::new() }; let voice_note_html = if !entry.meta.voice_note.is_empty() { format!( "", escape_html(&entry.meta.voice_note) ) } else { String::new() }; format!( "\n{header}\n{drawing_html}{voice_note_html}\n{body}\n\n{}\n", config.separator ) } #[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(), telegram_bot_token: None, telegram_chat_id: None, enable_honeypot: true, max_name_length: 0, max_message_length: 0, max_website_length: 0, enable_submissions: true, enable_website_links: true, enable_html_injection: false, enable_captcha: false, captcha_question: String::new(), captcha_answer: String::new(), captcha_exact: false, captcha_casesensitive: false, enable_drawings: false, canvas_width: 400, canvas_height: 200, enable_voice_notes: false, voice_note_max_duration: 20, template: None, success_template: None, separator: "---".into(), style: String::new(), form_prompt: "Thanks for visiting. Sign the guestbook!".into(), button_text: "sign".into(), label_name: "Your name:".into(), label_website: "Your website (optional):".into(), label_message: "Your message:".into(), textarea_width: 400, textarea_height: 150, } } fn make_entry(name: &str, date: &str, body: &str) -> Entry { Entry { id: "test".into(), meta: EntryMeta { name: name.into(), date: date.into(), website: String::new(), drawing: String::new(), voice_note: String::new(), status: Status::Approved, }, body: body.into(), } } #[test] fn test_render_default_template() { 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 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_classes() { let config = test_config(); let entry = make_entry("alice", "2026-04-09", "Hello!"); 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 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 mut config = test_config(); config.enable_html_injection = true; 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")); } #[test] fn test_render_empty_form_when_closed() { let config = test_config(); let html = render_page(DEFAULT_TEMPLATE, &config, &[], ""); assert!(!html.contains("action=\"/submit\"")); } #[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("