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!(
"{label} \n \n",
label = config.label_website
)
} else {
String::new()
};
let captcha_section = if config.enable_captcha {
format!(
"{label} \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#"
"#,
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}
"#,
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!(
" ",
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!(
"{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("