classes on all elements, configurable form labels/style/textarea

This commit is contained in:
Lewis Wynne 2026-04-09 17:11:59 +01:00
parent ef6a190549
commit b041941a4a
6 changed files with 240 additions and 46 deletions

View file

@ -10,4 +10,12 @@ BOOK_MAX_MESSAGE_LENGTH=1000
BOOK_MAX_WEBSITE_LENGTH=100 BOOK_MAX_WEBSITE_LENGTH=100
BOOK_OPEN_REGISTRATION=true BOOK_OPEN_REGISTRATION=true
BOOK_SEPARATOR=------------------------------------------------------------ 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 # BOOK_TEMPLATE=./templates/default.html

View file

@ -81,6 +81,54 @@ in
description = "Separator between guestbook entries."; 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 { templateFile = mkOption {
type = types.nullOr types.path; type = types.nullOr types.path;
default = null; default = null;
@ -128,6 +176,14 @@ in
BOOK_MAX_WEBSITE_LENGTH = toString cfg.maxWebsiteLength; BOOK_MAX_WEBSITE_LENGTH = toString cfg.maxWebsiteLength;
BOOK_OPEN_REGISTRATION = if cfg.openRegistration then "true" else "false"; BOOK_OPEN_REGISTRATION = if cfg.openRegistration then "true" else "false";
BOOK_SEPARATOR = cfg.separator; 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) { } // lib.optionalAttrs (cfg.templateFile != null) {
BOOK_TEMPLATE = cfg.templateFile; BOOK_TEMPLATE = cfg.templateFile;
}; };

View file

@ -16,6 +16,14 @@ pub struct Config {
pub open_registration: bool, pub open_registration: bool,
pub template: Option<String>, pub template: Option<String>,
pub separator: String, 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 { impl Config {
@ -64,6 +72,25 @@ impl Config {
std::fs::read_to_string(&path) std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("failed to read template {path}: {e}")) .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")?,
}) })
} }
} }

View file

@ -1,3 +1,4 @@
use crate::config::Config;
use crate::entries::Entry; use crate::entries::Entry;
pub const DEFAULT_TEMPLATE: &str = r#"<!DOCTYPE html> pub const DEFAULT_TEMPLATE: &str = r#"<!DOCTYPE html>
@ -10,13 +11,13 @@ pub const DEFAULT_TEMPLATE: &str = r#"<!DOCTYPE html>
pre { pre {
font: unset; font: unset;
max-width: 70ch; max-width: 70ch;
margin: 0 auto; margin: 0 auto;
padding: 1rem; padding: 1rem;
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;
} }
</style> </style>
{{style}}
</head> </head>
<body> <body>
<pre> <pre>
@ -35,12 +36,43 @@ entries
</html> </html>
"#; "#;
pub fn render_page(template: &str, title: &str, entries: &[Entry], form_html: &str, separator: &str) -> String { pub fn render_page(template: &str, config: &Config, entries: &[Entry], form_html: &str) -> String {
let entries_html = render_entries(entries, separator); let entries_html = render_entries(entries, &config.separator);
let style = if config.style.is_empty() {
String::new()
} else {
format!("<style>\n{}\n </style>", config.style)
};
template template
.replace("{{title}}", title) .replace("{{title}}", &config.site_title)
.replace("{{form}}", form_html) .replace("{{form}}", form_html)
.replace("{{entries}}", &entries_html) .replace("{{entries}}", &entries_html)
.replace("{{style}}", &style)
}
pub fn render_form(config: &Config) -> String {
format!(
r#"<span class="guestbook-prompt">{prompt}</span>
<form class="guestbook-form" method="post" action="/submit" accept-charset="UTF-8">
<label class="guestbook-label">{label_name}</label>
<input class="guestbook-input" name="name" required>
<label class="guestbook-label">{label_website}</label>
<input class="guestbook-input" name="website">
<label class="guestbook-label">{label_message}</label>
<textarea class="guestbook-textarea" name="message" rows="{rows}" cols="{cols}" required></textarea>
<input name="url" style="display:none" tabindex="-1" autocomplete="off">
<button class="guestbook-button" type="submit">{button}</button>
</form>"#,
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 { 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 { fn render_entry(entry: &Entry, separator: &str) -> String {
let mut header = format!("{} - {}", entry.meta.date, entry.meta.name); let mut header = format!(
"<span class=\"entry-header\">{} - <span class=\"entry-name\">{}</span>",
entry.meta.date, entry.meta.name
);
if !entry.meta.website.is_empty() { if !entry.meta.website.is_empty() {
header.push_str(&format!( header.push_str(&format!(
" (<a href=\"{}\">{}</a>)", " (<a class=\"entry-website\" href=\"{}\">{}</a>)",
entry.meta.website, entry.meta.website entry.meta.website, entry.meta.website
)); ));
} }
header.push_str("</span>");
format!( format!(
"\n{header}\n\n{}\n\n{separator}\n", "\n{header}\n\n<span class=\"entry-body\">{}</span>\n\n<span class=\"entry-separator\">{separator}</span>\n",
entry.body entry.body
) )
} }
pub const FORM_HTML: &str = r#"If you visited my site, please sign my guestbook!
<form method="post" action="/submit" accept-charset="UTF-8">
Your name:
<input name="name" required>
Your website (optional):
<input name="website">
Your message:
<textarea name="message" rows="8" cols="60" required></textarea>
<input name="url" style="display:none" tabindex="-1" autocomplete="off">
<button type="submit">sign</button>
</form>"#;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::entries::{Entry, EntryMeta, Status}; 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 { fn make_entry(name: &str, date: &str, body: &str) -> Entry {
Entry { Entry {
@ -99,48 +148,90 @@ mod tests {
#[test] #[test]
fn test_render_default_template() { fn test_render_default_template() {
let html = render_page(DEFAULT_TEMPLATE, "ily.rs", &[], FORM_HTML, "---"); let config = test_config();
assert!(html.contains("<title>ily.rs</title>")); let form = render_form(&config);
assert!(html.contains("action=\"/submit\"")); let html = render_page(DEFAULT_TEMPLATE, &config, &[], &form);
assert!(html.contains("<pre>")); assert!(html.contains("<title>test</title>"));
assert!(html.contains("guestbook-form"));
} }
#[test] #[test]
fn test_render_custom_template() { fn test_render_custom_template() {
let custom = "<html>{{title}} {{form}} {{entries}}</html>"; let config = test_config();
let html = render_page(custom, "my site", &[], FORM_HTML, "---"); let custom = "<html>{{title}} {{form}} {{entries}} {{style}}</html>";
assert!(html.contains("my site")); let form = render_form(&config);
assert!(html.contains("action=\"/submit\"")); let html = render_page(custom, &config, &[], &form);
assert!(html.contains("test"));
assert!(html.contains("guestbook-form"));
} }
#[test] #[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 entry = make_entry("alice", "2026-04-09", "Hello!");
let html = render_page(DEFAULT_TEMPLATE, "test", &[entry], FORM_HTML, "---"); let form = render_form(&config);
assert!(html.contains("2026-04-09 - alice")); let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form);
assert!(html.contains("Hello!")); assert!(html.contains("entry-header"));
assert!(html.contains("---")); assert!(html.contains("entry-name"));
assert!(html.contains("entry-body"));
assert!(html.contains("entry-separator"));
} }
#[test] #[test]
fn test_render_entry_with_website() { fn test_render_entry_with_website() {
let config = test_config();
let mut entry = make_entry("bob", "2026-04-09", "Hi!"); let mut entry = make_entry("bob", "2026-04-09", "Hi!");
entry.meta.website = "https://bob.com".into(); entry.meta.website = "https://bob.com".into();
let html = render_page(DEFAULT_TEMPLATE, "test", &[entry], FORM_HTML, "---"); let form = render_form(&config);
assert!(html.contains(r#"<a href="https://bob.com">"#)); let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form);
assert!(html.contains("entry-website"));
assert!(html.contains(r#"href="https://bob.com">"#));
} }
#[test] #[test]
fn test_render_preserves_html_in_body() { fn test_render_preserves_html_in_body() {
let entry = make_entry("carol", "2026-04-09", "<b>Bold</b> <script>alert(1)</script>"); let config = test_config();
let html = render_page(DEFAULT_TEMPLATE, "test", &[entry], FORM_HTML, "---"); let entry = make_entry("carol", "2026-04-09", "<b>Bold</b>");
let form = render_form(&config);
let html = render_page(DEFAULT_TEMPLATE, &config, &[entry], &form);
assert!(html.contains("<b>Bold</b>")); assert!(html.contains("<b>Bold</b>"));
assert!(html.contains("<script>alert(1)</script>"));
} }
#[test] #[test]
fn test_render_empty_form_when_closed() { fn test_render_empty_form_when_closed() {
let html = render_page(DEFAULT_TEMPLATE, "test", &[], "", "---"); let config = test_config();
assert!(!html.contains("action=\"/submit\"")); 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>"));
}
#[test]
fn test_render_form_custom_labels() {
let mut config = test_config();
config.form_prompt = "Leave a note!".into();
config.button_text = "submit".into();
config.label_name = "Name:".into();
let form = render_form(&config);
assert!(form.contains("Leave a note!"));
assert!(form.contains("submit"));
assert!(form.contains("Name:"));
}
#[test]
fn test_render_form_custom_textarea() {
let mut config = test_config();
config.textarea_rows = 12;
config.textarea_cols = 40;
let form = render_form(&config);
assert!(form.contains("rows=\"12\""));
assert!(form.contains("cols=\"40\""));
} }
} }

View file

@ -10,7 +10,7 @@ use uuid::Uuid;
use crate::config::Config; use crate::config::Config;
use crate::entries::{self, Entry, EntryMeta, Status}; use crate::entries::{self, Entry, EntryMeta, Status};
use crate::render::{self, DEFAULT_TEMPLATE, FORM_HTML}; use crate::render::{self, DEFAULT_TEMPLATE};
pub struct AppState { pub struct AppState {
pub config: Config, pub config: Config,
@ -37,14 +37,17 @@ pub fn router(state: Arc<AppState>) -> Router {
async fn index(State(state): State<Arc<AppState>>) -> Html<String> { async fn index(State(state): State<Arc<AppState>>) -> Html<String> {
let entries_dir = state.config.data_dir.join("entries"); let entries_dir = state.config.data_dir.join("entries");
let entries = entries::read_approved(&entries_dir); let entries = entries::read_approved(&entries_dir);
let form = if state.config.open_registration { FORM_HTML } else { "" }; let form = if state.config.open_registration {
render::render_form(&state.config)
} else {
String::new()
};
let template = state.config.template.as_deref().unwrap_or(DEFAULT_TEMPLATE); let template = state.config.template.as_deref().unwrap_or(DEFAULT_TEMPLATE);
let html = render::render_page( let html = render::render_page(
template, template,
&state.config.site_title, &state.config,
&entries, &entries,
form, &form,
&state.config.separator,
); );
Html(html) Html(html)
} }
@ -136,6 +139,14 @@ mod tests {
open_registration: true, open_registration: true,
template: None, template: None,
separator: "---".into(), 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,
} }
} }

View file

@ -15,6 +15,7 @@
word-wrap: break-word; word-wrap: break-word;
} }
</style> </style>
{{style}}
</head> </head>
<body> <body>
<pre> <pre>