use crate::{Emit, push_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 {
let mut id = String::new();
if let Some(prefix) = self.id_prefix {
escape_into(prefix, &mut id);
id.push('-');
}
escape_into(name, &mut id);
id
}
}
pub fn escape_into(text: &str, out: &mut String) {
let mut start = 0;
for (index, byte) in text.bytes().enumerate() {
let encoded = match byte {
b'&' => "&",
b'<' => "<",
b'>' => ">",
b'"' => """,
b'\'' => "'",
_ => continue,
};
out.push_str(&text[start..index]);
out.push_str(encoded);
start = index + 1;
}
out.push_str(&text[start..]);
}
#[must_use]
pub fn escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
escape_into(text, &mut out);
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::Date => "date",
FieldKind::DateTime => "datetime-local",
FieldKind::Radio => "radio",
FieldKind::Text | FieldKind::Select | FieldKind::Textarea => "text",
_ => "text",
}
}
fn push_control_attributes(out: &mut String, field: &Field<'_>, id: &str, name: &str) {
let _ = write!(out, " id=\"{id}\" name=\"");
escape_into(name, out);
out.push('"');
if field.required {
out.push_str(" required");
}
if let Some(limit) = field.max_length {
let _ = write!(out, " maxlength=\"{limit}\"");
}
if let Some(min) = field.min {
out.push_str(" min=\"");
escape_into(min, out);
out.push('"');
}
if let Some(max) = field.max {
out.push_str(" max=\"");
escape_into(max, out);
out.push('"');
}
if field.invalid() {
out.push_str(" aria-invalid=\"true\"");
}
push_described_by(out, field, id);
}
fn push_described_by(out: &mut String, field: &Field<'_>, id: &str) {
if field.hint.is_none() && field.error.is_none() {
return;
}
out.push_str(" aria-describedby=\"");
if field.hint.is_some() {
let _ = write!(out, "{id}-hint");
}
if field.error.is_some() {
if field.hint.is_some() {
out.push(' ');
}
let _ = write!(out, "{id}-error");
}
out.push('"');
}
const fn is_group_control(kind: FieldKind) -> bool {
matches!(kind, FieldKind::Radio)
}
fn push_radio(out: &mut String, field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) {
let id = filling.id_for(field.name);
let value = filling.value.as_text();
let name = escape(field.name);
out.push_str("<div class=\"");
push_class(out, "form-radio-group", opts);
let _ = write!(out, "\" role=\"radiogroup\" aria-labelledby=\"{id}-label\"");
if field.invalid() {
out.push_str(" aria-invalid=\"true\"");
}
push_described_by(out, field, &id);
out.push('>');
for (index, opt) in field.options.iter().enumerate() {
out.push_str("<label class=\"");
push_class(out, "form-radio-label", opts);
let _ = write!(
out,
"\"><input type=\"radio\" id=\"{id}-{index}\" name=\"{name}\" value=\""
);
escape_into(opt.value, out);
out.push('"');
if opt.value == value {
out.push_str(" checked");
}
if field.required {
out.push_str(" required");
}
out.push_str("><span>");
escape_into(opt.label, out);
out.push_str("</span></label>");
}
out.push_str("</div>");
}
fn push_options(out: &mut String, options: &[Choice<'_>], value: &str) {
if !value.is_empty() && !options.iter().any(|opt| opt.value == value) {
let escaped = escape(value);
let _ = write!(
out,
"<option value=\"{escaped}\" selected data-unmatched=\"true\">{escaped}</option>"
);
}
for opt in options {
out.push_str("<option value=\"");
escape_into(opt.value, out);
out.push('"');
if opt.value == value {
out.push_str(" selected");
}
out.push('>');
escape_into(opt.label, out);
out.push_str("</option>");
}
}
fn push_control(out: &mut String, field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) {
if matches!(field.kind, FieldKind::Radio) {
push_radio(out, field, filling, opts);
return;
}
let id = filling.id_for(field.name);
let placeholder = |out: &mut String| {
if let Some(text) = field.placeholder {
out.push_str(" placeholder=\"");
escape_into(text, out);
out.push('"');
}
};
match field.kind {
FieldKind::Textarea => {
out.push_str("<textarea class=\"");
push_class(out, "field", opts);
out.push('"');
push_control_attributes(out, field, &id, field.name);
placeholder(out);
out.push('>');
escape_into(filling.value.as_text(), out);
out.push_str("</textarea>");
}
FieldKind::Select => {
out.push_str("<select class=\"");
push_class(out, "field", opts);
out.push('"');
push_control_attributes(out, field, &id, field.name);
out.push('>');
push_options(out, field.options, filling.value.as_text());
out.push_str("</select>");
}
FieldKind::Checkbox => {
out.push_str("<label class=\"");
push_class(out, "form-checkbox-label", opts);
out.push_str("\"><input type=\"checkbox\"");
push_control_attributes(out, field, &id, field.name);
if matches!(filling.value, Value::On(true)) {
out.push_str(" checked");
}
out.push_str("><span>");
escape_into(field.label, out);
out.push_str("</span></label>");
}
FieldKind::Secret => {
out.push_str("<input type=\"password\" class=\"");
push_class(out, "field", opts);
out.push('"');
push_control_attributes(out, field, &id, field.name);
placeholder(out);
out.push('>');
}
FieldKind::File => {
out.push_str("<input type=\"file\" class=\"");
push_class(out, "field", opts);
out.push('"');
push_control_attributes(out, field, &id, field.name);
out.push('>');
}
kind => {
let _ = write!(out, "<input type=\"{}\" class=\"", input_type(kind));
push_class(out, "field", opts);
out.push('"');
push_control_attributes(out, field, &id, field.name);
placeholder(out);
out.push_str(" value=\"");
escape_into(filling.value.as_text(), out);
out.push_str("\">");
}
}
}
#[must_use]
pub fn field_html(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit) -> String {
let mut html = String::new();
field_html_into(field, filling, opts, &mut html);
html
}
pub fn field_html_into(field: &Field<'_>, filling: &Filling<'_>, opts: &Emit, out: &mut String) {
let id = filling.id_for(field.name);
if !field.kind.visible() {
out.push_str("<input type=\"hidden\" name=\"");
escape_into(field.name, out);
out.push_str("\" value=\"");
escape_into(filling.value.as_text(), out);
out.push_str("\">");
return;
}
out.push_str("<div class=\"");
push_class(out, "form-group", opts);
if field.invalid() {
out.push_str(" has-error");
}
if field.extended {
out.push_str("\" data-extended=\"true");
}
out.push_str("\">");
if !field.kind.labels_itself() {
out.push_str("<label class=\"");
push_class(out, "form-label", opts);
if is_group_control(field.kind) {
let _ = write!(out, "\" id=\"{id}-label\">");
} else {
let _ = write!(out, "\" for=\"{id}\">");
}
escape_into(field.label, out);
out.push_str("</label>");
}
push_control(out, field, filling, opts);
if let Some(hint) = field.hint {
out.push_str("<div class=\"");
push_class(out, "form-hint", opts);
let _ = write!(out, "\" id=\"{id}-hint\">");
escape_into(hint, out);
out.push_str("</div>");
}
if let Some(Markup(markup)) = filling.trailing {
out.push_str(markup);
}
if let Some(error) = field.error {
out.push_str("<div class=\"");
push_class(out, "form-error", opts);
let _ = write!(out, " visible\" id=\"{id}-error\" role=\"alert\">");
escape_into(error, out);
out.push_str("</div>");
}
out.push_str("</div>");
}
#[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 the_streaming_escaper_appends_what_the_returning_one_returns() {
for text in [
"",
"plain",
"&<>\"'",
"&&&",
"a & b",
"trailing&",
"&leading",
"é世 & <b>naïve</b> \u{1f600}",
] {
let mut out = String::from("kept: ");
escape_into(text, &mut out);
assert_eq!(out, format!("kept: {}", escape(text)), "{text:?}");
}
}
#[test]
fn a_streamed_field_is_the_field_the_other_form_returns() {
let kinds = [
FieldKind::Text,
FieldKind::Secret,
FieldKind::Number,
FieldKind::Checkbox,
FieldKind::Radio,
FieldKind::Select,
FieldKind::Textarea,
FieldKind::File,
FieldKind::Hidden,
];
let choices = [Choice::plain("one"), Choice::plain("two")];
let opts = Emit {
class_prefix: "mk-",
..Emit::default()
};
for kind in kinds {
let described = Field {
hint: Some("a hint"),
error: Some("wrong <here>"),
placeholder: Some("x\" y"),
options: &choices,
required: true,
max_length: Some(40),
min: Some("1"),
max: Some("9"),
extended: true,
..Field::new(kind, "the & name", "The <label>")
};
let filling = Filling {
value: Value::Text("one"),
trailing: Some(Markup("<i>t</i>")),
id_prefix: Some("modal"),
};
let mut streamed = String::new();
field_html_into(&described, &filling, &opts, &mut streamed);
assert_eq!(
streamed,
field_html(&described, &filling, &opts),
"{kind:?}"
);
let plain = Field::new(kind, "name", "Label");
let mut streamed = String::new();
field_html_into(&plain, &Filling::default(), &opts, &mut streamed);
assert_eq!(
streamed,
field_html(&plain, &Filling::default(), &opts),
"{kind:?}"
);
}
}
#[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"),
(FieldKind::Date, "date"),
(FieldKind::DateTime, "datetime-local"),
] {
let html = field_html(&field(kind), &Filling::default(), &Emit::default());
assert!(
html.contains(&format!(r#"type="{expected}""#)),
"{kind:?} emitted {html}"
);
}
}
#[test]
fn a_temporal_field_is_a_native_control_and_not_a_hinted_text_box() {
for kind in [FieldKind::Date, FieldKind::DateTime] {
let html = field_html(&field(kind), &Filling::default(), &Emit::default());
assert!(!html.contains(r#"type="text""#), "{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}");
}
}