use crate::{Emit, class};
use makeover_layout::{Choice, Field, FieldKind};
use std::fmt::Write as _;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Markup<'a>(pub &'a str);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Value<'a> {
#[default]
Absent,
Text(&'a str),
On(bool),
}
impl<'a> Value<'a> {
const fn as_text(&self) -> &'a str {
match self {
Self::Text(text) => text,
Self::Absent | Self::On(_) => "",
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Filling<'a> {
pub value: Value<'a>,
pub trailing: Option<Markup<'a>>,
pub id_prefix: Option<&'a str>,
}
impl<'a> Filling<'a> {
#[must_use]
pub const fn of(value: Value<'a>) -> Self {
Self {
value,
trailing: None,
id_prefix: None,
}
}
fn id_for(&self, name: &str) -> String {
match self.id_prefix {
Some(prefix) => format!("{}-{}", escape(prefix), escape(name)),
None => escape(name),
}
}
}
#[must_use]
pub fn escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
other => out.push(other),
}
}
out
}
const fn input_type(kind: FieldKind) -> &'static str {
match kind {
FieldKind::Secret => "password",
FieldKind::Number => "number",
FieldKind::Checkbox => "checkbox",
FieldKind::File => "file",
FieldKind::Hidden => "hidden",
FieldKind::Email => "email",
FieldKind::Url => "url",
FieldKind::Tel => "tel",
FieldKind::Radio => "radio",
FieldKind::Text | FieldKind::Select | FieldKind::Textarea => "text",
_ => "text",
}
}
fn control_attributes(field: &Field<'_>, id: &str, name: &str) -> String {
let mut attrs = format!(" id=\"{id}\" name=\"{}\"", escape(name));
if field.required {
attrs.push_str(" required");
}
if let Some(limit) = field.max_length {
let _ = write!(attrs, " maxlength=\"{limit}\"");
}
if let Some(min) = field.min {
let _ = write!(attrs, " min=\"{}\"", escape(min));
}
if let Some(max) = field.max {
let _ = write!(attrs, " max=\"{}\"", escape(max));
}
if field.invalid() {
attrs.push_str(" aria-invalid=\"true\"");
}
attrs.push_str(&described_by(field, id));
attrs
}
fn described_by(field: &Field<'_>, id: &str) -> String {
let mut described = Vec::new();
if field.hint.is_some() {
described.push(format!("{id}-hint"));
}
if field.error.is_some() {
described.push(format!("{id}-error"));
}
if described.is_empty() {
return String::new();
}
format!(" aria-describedby=\"{}\"", described.join(" "))
}
const fn is_group_control(kind: FieldKind) -> bool {
matches!(kind, FieldKind::Radio)
}
fn radio_html(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) -> String {
let id = filling.id_for(field.name);
let value = filling.value.as_text();
let name = escape(field.name);
let mut html = format!(
"<div class=\"{}\" role=\"radiogroup\"",
class("form-radio-group", opts)
);
let _ = write!(html, " aria-labelledby=\"{id}-label\"");
if field.invalid() {
html.push_str(" aria-invalid=\"true\"");
}
html.push_str(&described_by(field, &id));
html.push('>');
for (index, opt) in field.options.iter().enumerate() {
let checked = if opt.value == value { " checked" } else { "" };
let required = if field.required { " required" } else { "" };
let _ = write!(
html,
"<label class=\"{}\"><input type=\"radio\" id=\"{id}-{index}\" name=\"{name}\" \
value=\"{}\"{checked}{required}><span>{}</span></label>",
class("form-radio-label", opts),
escape(opt.value),
escape(opt.label)
);
}
html.push_str("</div>");
html
}
fn options_html(options: &[Choice<'_>], value: &str) -> String {
let mut html = String::new();
if !value.is_empty() && !options.iter().any(|opt| opt.value == value) {
let escaped = escape(value);
let _ = write!(
html,
"<option value=\"{escaped}\" selected data-unmatched=\"true\">{escaped}</option>"
);
}
for opt in options {
let selected = if opt.value == value { " selected" } else { "" };
let _ = write!(
html,
"<option value=\"{}\"{selected}>{}</option>",
escape(opt.value),
escape(opt.label)
);
}
html
}
fn control_html(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) -> String {
let id = filling.id_for(field.name);
let attrs = control_attributes(field, &id, field.name);
let field_class = class("field", opts);
let placeholder = field.placeholder.map_or_else(String::new, |text| {
format!(" placeholder=\"{}\"", escape(text))
});
match field.kind {
FieldKind::Radio => radio_html(field, filling, opts),
FieldKind::Textarea => format!(
"<textarea class=\"{field_class}\"{attrs}{placeholder}>{}</textarea>",
escape(filling.value.as_text())
),
FieldKind::Select => {
let options = options_html(field.options, filling.value.as_text());
format!("<select class=\"{field_class}\"{attrs}>{options}</select>")
}
FieldKind::Checkbox => {
let checked = if matches!(filling.value, Value::On(true)) {
" checked"
} else {
""
};
format!(
"<label class=\"{}\"><input type=\"checkbox\"{attrs}{checked}><span>{}</span></label>",
class("form-checkbox-label", opts),
escape(field.label)
)
}
FieldKind::Secret => {
format!("<input type=\"password\" class=\"{field_class}\"{attrs}{placeholder}>")
}
FieldKind::File => {
format!("<input type=\"file\" class=\"{field_class}\"{attrs}>")
}
kind => format!(
"<input type=\"{}\" class=\"{field_class}\"{attrs}{placeholder} value=\"{}\">",
input_type(kind),
escape(filling.value.as_text())
),
}
}
#[must_use]
pub fn field_html(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) -> String {
let id = filling.id_for(field.name);
if !field.kind.visible() {
return format!(
"<input type=\"hidden\" name=\"{}\" value=\"{}\">",
escape(field.name),
escape(filling.value.as_text())
);
}
let mut html = format!("<div class=\"{}", class("form-group", opts));
if field.invalid() {
html.push_str(" has-error");
}
if field.extended {
html.push_str("\" data-extended=\"true");
}
html.push_str("\">");
if !field.kind.labels_itself() {
let association = if is_group_control(field.kind) {
format!(" id=\"{id}-label\"")
} else {
format!(" for=\"{id}\"")
};
let _ = write!(
html,
"<label class=\"{}\"{association}>{}</label>",
class("form-label", opts),
escape(field.label)
);
}
html.push_str(&control_html(field, filling, opts));
if let Some(hint) = field.hint {
let _ = write!(
html,
"<div class=\"{}\" id=\"{id}-hint\">{}</div>",
class("form-hint", opts),
escape(hint)
);
}
if let Some(Markup(markup)) = filling.trailing {
html.push_str(markup);
}
if let Some(error) = field.error {
let _ = write!(
html,
"<div class=\"{} visible\" id=\"{id}-error\" role=\"alert\">{}</div>",
class("form-error", opts),
escape(error)
);
}
html.push_str("</div>");
html
}
#[cfg(test)]
mod tests {
use super::*;
fn field(kind: FieldKind) -> Field<'static> {
Field::new(kind, "title", "Title")
}
#[test]
fn a_value_cannot_break_out_of_the_attribute_it_sits_in() {
let filling = Filling::of(Value::Text("x\" onfocus=alert(1) autofocus=\""));
let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
assert!(!html.contains("\" onfocus"), "{html}");
assert!(
html.contains("value=\"x" onfocus=alert(1) autofocus="\""),
"{html}"
);
}
#[test]
fn a_label_cannot_open_a_tag() {
let mut f = field(FieldKind::Text);
f.label = "<script>alert(1)</script>";
let html = field_html(&f, &Filling::default(), &Emit::default());
assert!(!html.contains("<script>"), "{html}");
assert!(html.contains("<script>"), "{html}");
}
#[test]
fn every_escaped_sink_is_covered_by_the_one_escaper() {
assert_eq!(escape("&<>\"'"), "&<>"'");
assert!(escape("\"").contains("""));
}
#[test]
fn markup_is_the_only_way_past_the_escaping() {
let filling = Filling {
trailing: Some(Markup("<div class=\"recurrence-config\"></div>")),
..Filling::default()
};
let html = field_html(&field(FieldKind::Text), &filling, &Emit::default());
assert!(
html.contains("<div class=\"recurrence-config\"></div>"),
"{html}"
);
}
#[test]
fn an_invalid_field_carries_the_attribute_its_own_stylesheet_keys_on() {
let mut f = field(FieldKind::Text);
f.error = Some("Required");
let opts = Emit::default();
let html = field_html(&f, &Filling::default(), &opts);
assert!(html.contains("aria-invalid=\"true\""), "{html}");
assert!(crate::stylesheet(&opts).contains("[aria-invalid=\"true\"]"));
assert!(html.contains("has-error"), "{html}");
}
#[test]
fn a_valid_field_claims_nothing_about_being_invalid() {
let html = field_html(
&field(FieldKind::Text),
&Filling::default(),
&Emit::default(),
);
assert!(!html.contains("aria-invalid"), "{html}");
assert!(!html.contains("has-error"), "{html}");
}
#[test]
fn the_hint_survives_an_error_arriving() {
let mut f = field(FieldKind::Text);
f.hint = Some("Keep it short");
f.error = Some("Required");
let html = field_html(&f, &Filling::default(), &Emit::default());
assert!(
html.contains("aria-describedby=\"title-hint title-error\""),
"{html}"
);
}
#[test]
fn a_secret_never_carries_its_value_into_the_markup() {
let filling = Filling::of(Value::Text("hunter2"));
let html = field_html(&field(FieldKind::Secret), &filling, &Emit::default());
assert!(!html.contains("hunter2"), "{html}");
assert!(html.contains("type=\"password\""), "{html}");
}
#[test]
fn a_hidden_field_is_the_input_and_nothing_else() {
let filling = Filling::of(Value::Text("42"));
let html = field_html(&field(FieldKind::Hidden), &filling, &Emit::default());
assert_eq!(html, "<input type=\"hidden\" name=\"title\" value=\"42\">");
}
#[test]
fn a_checkbox_labels_itself_and_takes_no_separate_label() {
let html = field_html(
&field(FieldKind::Checkbox),
&Filling::of(Value::On(true)),
&Emit::default(),
);
assert!(!html.contains("form-label"), "{html}");
assert!(html.contains("checked"), "{html}");
assert!(html.contains("<span>Title</span>"), "{html}");
}
#[test]
fn a_select_keeps_a_value_no_option_carries() {
let options = [Choice::plain("1"), Choice::plain("3"), Choice::plain("7")];
let f = Field::select("title", "Title", &options);
let html = field_html(&f, &Filling::of(Value::Text("10")), &Emit::default());
assert!(html.contains("data-unmatched=\"true\""), "{html}");
assert!(html.contains("<option value=\"10\" selected"), "{html}");
}
#[test]
fn a_select_with_no_options_emits_an_empty_select() {
let f = Field::select("title", "Title", &[]);
let html = field_html(&f, &Filling::default(), &Emit::default());
assert!(html.contains("<select"), "{html}");
assert!(!html.contains("<option"), "{html}");
}
#[test]
fn a_radio_group_is_named_by_its_label_instead_of_pointing_at_it() {
let options = [Choice::plain("copy"), Choice::plain("reference")];
let f = Field::radio("storage", "Storage style", &options);
let html = field_html(&f, &Filling::of(Value::Text("copy")), &Emit::default());
assert!(html.contains("id=\"storage-label\""), "{html}");
assert!(!html.contains("for=\"storage\""), "{html}");
assert!(html.contains("role=\"radiogroup\""), "{html}");
assert!(html.contains("aria-labelledby=\"storage-label\""), "{html}");
}
#[test]
fn every_option_shares_the_name_and_only_the_current_one_is_checked() {
let options = [
Choice::plain("copy"),
Choice::plain("reference"),
Choice::plain("link"),
];
let f = Field::radio("storage", "Storage style", &options);
let html = field_html(&f, &Filling::of(Value::Text("reference")), &Emit::default());
assert_eq!(html.matches("name=\"storage\"").count(), 3, "{html}");
assert_eq!(html.matches(" checked").count(), 1, "{html}");
assert!(
html.contains("value=\"reference\" checked"),
"the checked one is the one held: {html}"
);
for index in 0..3 {
assert!(html.contains(&format!("id=\"storage-{index}\"")), "{html}");
}
}
#[test]
fn a_radio_group_carries_the_error_rather_than_any_one_option() {
let options = [Choice::plain("copy"), Choice::plain("reference")];
let f = Field {
error: Some("Pick one."),
hint: Some("Cannot be changed later."),
..Field::radio("storage", "Storage style", &options)
};
let html = field_html(&f, &Filling::default(), &Emit::default());
assert_eq!(html.matches("aria-invalid=\"true\"").count(), 1, "{html}");
assert!(
html.contains("aria-describedby=\"storage-hint storage-error\""),
"{html}"
);
let group = html.find("role=\"radiogroup\"").expect("group");
let first = html.find("type=\"radio\"").expect("an option");
assert!(group < first, "{html}");
}
#[test]
fn a_compulsory_radio_group_marks_every_option() {
let options = [Choice::plain("copy"), Choice::plain("reference")];
let f = Field {
required: true,
..Field::radio("storage", "Storage style", &options)
};
let html = field_html(&f, &Filling::default(), &Emit::default());
assert_eq!(html.matches(" required").count(), 2, "{html}");
}
#[test]
fn a_radio_option_cannot_break_out_of_its_attribute() {
let hostile = [Choice {
value: "x\" onclick=alert(1) data-x=\"",
label: "<script>alert(1)</script>",
}];
let f = Field::radio("storage", "Storage style", &hostile);
let html = field_html(&f, &Filling::default(), &Emit::default());
assert!(html.contains("value=\"x" onclick=alert(1)"), "{html}");
assert!(!html.contains("<script>"), "{html}");
assert!(html.contains("id=\"storage-0\""), "{html}");
}
#[test]
fn a_radio_group_with_no_options_emits_an_empty_group() {
let f = Field::radio("storage", "Storage style", &[]);
let html = field_html(&f, &Filling::default(), &Emit::default());
assert!(html.contains("role=\"radiogroup\""), "{html}");
assert!(!html.contains("type=\"radio\""), "{html}");
}
#[test]
fn a_placeholder_comes_off_the_description_and_is_escaped() {
let f = Field {
placeholder: Some("x\" onfocus=alert(1) autofocus=\""),
..field(FieldKind::Text)
};
let html = field_html(&f, &Filling::default(), &Emit::default());
assert!(html.contains("placeholder=\""), "{html}");
assert!(!html.contains("\" onfocus"), "{html}");
}
#[test]
fn a_select_marks_the_option_that_matches() {
let options = [Choice::plain("1"), Choice::plain("3")];
let f = Field::select("title", "Title", &options);
let html = field_html(&f, &Filling::of(Value::Text("3")), &Emit::default());
assert!(
html.contains("<option value=\"3\" selected>3</option>"),
"{html}"
);
assert!(html.contains("<option value=\"1\">1</option>"), "{html}");
assert!(!html.contains("data-unmatched"), "{html}");
}
#[test]
fn a_textarea_carries_its_value_as_text_and_not_as_an_attribute() {
let filling = Filling::of(Value::Text("two\nlines"));
let html = field_html(&field(FieldKind::Textarea), &filling, &Emit::default());
assert!(html.contains(">two\nlines</textarea>"), "{html}");
}
#[test]
fn the_class_prefix_reaches_the_markup_as_well_as_the_stylesheet() {
let opts = Emit {
class_prefix: "mk-",
..Emit::default()
};
let html = field_html(&field(FieldKind::Text), &Filling::default(), &opts);
assert!(html.contains("class=\"mk-form-group\""), "{html}");
assert!(html.contains("class=\"mk-field\""), "{html}");
}
#[test]
fn an_extended_field_says_so_and_leaves_the_disclosure_to_the_form() {
let mut f = field(FieldKind::Text);
f.extended = true;
let html = field_html(&f, &Filling::default(), &Emit::default());
assert!(html.contains("data-extended=\"true\""), "{html}");
}
#[test]
fn the_id_prefix_scopes_the_id_and_never_the_name() {
let mut f = field(FieldKind::Text);
f.hint = Some("Keep it short");
f.error = Some("Required");
let filling = Filling {
id_prefix: Some("form-modal-task-edit"),
..Filling::default()
};
let html = field_html(&f, &filling, &Emit::default());
assert!(
html.contains(r#"id="form-modal-task-edit-title""#),
"{html}"
);
assert!(html.contains(r#"name="title""#), "{html}");
assert!(
!html.contains(r#"name="form-modal-task-edit-title""#),
"{html}"
);
assert!(
html.contains(r#"for="form-modal-task-edit-title""#),
"{html}"
);
assert!(
html.contains(
r#"aria-describedby="form-modal-task-edit-title-hint form-modal-task-edit-title-error""#
),
"{html}"
);
assert!(
html.contains(r#"id="form-modal-task-edit-title-hint""#),
"{html}"
);
}
#[test]
fn a_hidden_field_submits_its_bare_name_under_a_prefix() {
let filling = Filling {
value: Value::Text("42"),
id_prefix: Some("scoped"),
..Filling::default()
};
let html = field_html(&field(FieldKind::Hidden), &filling, &Emit::default());
assert_eq!(html, r#"<input type="hidden" name="title" value="42">"#);
}
#[test]
fn a_constraint_becomes_the_browsers_own_attribute() {
let html = field_html(
&Field {
max_length: Some(100),
min: Some("1"),
max: Some("240"),
required: true,
..Field::new(FieldKind::Number, "minutes", "Minutes")
},
&Filling::default(),
&Emit::default(),
);
assert!(html.contains(r#"maxlength="100""#));
assert!(html.contains(r#"min="1""#));
assert!(html.contains(r#"max="240""#));
assert!(html.contains(" required"));
}
#[test]
fn a_bound_is_emitted_as_written_and_escaped_like_anything_else() {
let html = field_html(
&Field {
min: Some("2026-08-09T14:30"),
..Field::new(FieldKind::Text, "starts", "Starts")
},
&Filling::default(),
&Emit::default(),
);
assert!(html.contains(r#"min="2026-08-09T14:30""#));
}
#[test]
fn a_file_field_is_a_file_input() {
let html = field_html(
&Field::new(FieldKind::File, "attachment", "Attachment"),
&Filling::default(),
&Emit::default(),
);
assert!(html.contains(r#"type="file""#));
assert!(!html.contains("accept="));
assert!(!html.contains("value="));
}
#[test]
fn the_typed_text_kinds_keep_their_input_type() {
for (kind, expected) in [
(FieldKind::Email, "email"),
(FieldKind::Url, "url"),
(FieldKind::Tel, "tel"),
] {
let html = field_html(&field(kind), &Filling::default(), &Emit::default());
assert!(
html.contains(&format!(r#"type="{expected}""#)),
"{kind:?} emitted {html}"
);
}
}
#[test]
fn no_prefix_leaves_the_id_as_the_name() {
let html = field_html(
&field(FieldKind::Text),
&Filling::default(),
&Emit::default(),
);
assert!(html.contains(r#"id="title" name="title""#), "{html}");
}
}