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!(""); template .replace("{{title}}", &config.site_title) .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", label = config.label_website ) } else { String::new() }; let captcha_section = if config.enable_captcha { format!( "\n\n", label = config.captcha_question ) } else { String::new() }; let drawing_section = if config.enable_drawings { format!( r##"{label} "##, label = config.label_drawing, w = config.canvas_width, h = config.canvas_height, ) } else { String::new() }; let voice_note_section = if config.enable_voice_notes { format!( r##"{label} "##, label = config.label_voice_note, record = config.voice_note_record_text, max_dur = config.voice_note_max_duration, ) } else { String::new() }; format!( r#"
{website_section} {drawing_section}{voice_note_section}{captcha_section}
"#, label_name = config.label_name, website_section = website_section, label_message = config.label_message, tw = config.textarea_width, th = config.textarea_height, drawing_section = drawing_section, voice_note_section = voice_note_section, captcha_section = captcha_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 { if entries.is_empty() { return String::new(); } let mut html = String::from("
"); for entry in entries { html.push_str(&render_entry(entry, config)); } html.push_str("
"); 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 name_html = if config.enable_website_links && !entry.meta.website.is_empty() { format!( "{}", escape_html(&entry.meta.website), name ) } else { name }; 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() }; let body_html = if body.is_empty() { String::new() } else { format!("
{body}
") }; let date = &entry.meta.date[..10]; format!( "
{date} {name_html}
{body_html}{drawing_html}{voice_note_html}", id = escape_html(&entry.id), ) } #[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(), #[cfg(feature = "telegram")] telegram_bot_token: None, #[cfg(feature = "telegram")] telegram_chat_id: None, telegram_retry_interval: 20, telegram_retry_limit: 3, telegram_reminder_interval: 86400, 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, style: String::new(), button_text: "sign".into(), label_name: "name".into(), label_website: "website (optional)".into(), label_message: "message (optional)".into(), label_drawing: "drawing (optional)".into(), label_voice_note: "voice note (optional)".into(), voice_note_record_text: "record".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("id=\"test\"")); assert!(html.contains("
")); } #[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("