# Form Contracts — declarative HTML forms, served and delivered WITHOUT a build. # # ───────────────────────────────────────────────────────────────────────────── # WHY THIS EXISTS # # Before this, every form meant a typed `struct` in Rust, a handler, a `nest(...)` and a # RECOMPILE of the shared image. So a form was the one part of a page you could not add: the # template, the Fluent text, the content and (since `merge_site_routes`) the route were all # already the consumer's. The endpoint was the last baked thing. # # The result was predictable and it is on the record: `work-request` — a public # "request a project quote" form with eleven fields — has a template and NO handler. # `POST /api/work-request` is registered nowhere in the stack. The page renders, the visitor # fills it in, and the submission goes to a route that does not exist. It has been like that # long enough that nobody noticed, because a form that renders LOOKS like a form that works. # # And the naive repair was worse. `ContactForm` (htmx_contact.rs) declares four fields and does # NOT set `deny_unknown_fields`, so serde SILENTLY DROPS anything it does not know. Wiring # work-request to it would have returned 200, said "sent", and delivered an email containing the # name and the email address — with the project description, the budget and the timeline gone. # A form that appears to work and throws the data away is worse than one that visibly fails. # # THE FIX IS THE SAME MOVE AS THE ROUTES. The form is DECLARED, in NCL, by the site. One generic # handler reads the declaration and delivers what the declaration names. A new form is a `.ncl` # and a template — no Rust, no build. # # And it can be NCL rather than TOML precisely because the runtime already reads NCL: the htmx # shell loads `routes.ncl` and `footer.ncl` through `rustelo_config::format::load_config`, which # dispatches `.ncl` to `nickel::export_to_json`. No new dependency; the one that is already there. # # WHAT THE CONTRACT BUYS OVER A BARE MAP. The handler accepts a `HashMap` — no # per-form struct, which is the whole point. The contract is what keeps that from becoming a # free-for-all: # # · a field not in `fields` is REFUSED, not silently dropped. The failure mode that made this # necessary cannot recur here — it is exactly what serde's unknown-field default did. # · `required` is checked before delivery, not after. # · `deliver_to` names an env var, never a literal address: a recipient in git is a recipient # harvested. # · every declared field appears in the composed body, in declaration order. What the form # collects is what the owner receives. # ───────────────────────────────────────────────────────────────────────────── let field_kind = [| 'Text, 'Email, 'Textarea, 'Select, # A honeypot. Rendered hidden; a non-empty value means a bot, and the handler confirms # WITHOUT delivering, so the bot gets no signal that it was caught. 'Honeypot, |] in let field_type = { # The form-encoded name. This is the contract with the template: a field the template posts # and the spec does not declare is REFUSED by the handler. name | String, kind | field_kind | default = 'Text, required | Bool | default = false, # Fluent key for the label. The text lives where all text lives. label_key | String | default = "", # Fluent key for the placeholder / help text. hint_key | String | default = "", # For 'Select: the option values. Their labels are `-` in Fluent. options | Array String | default = [], # Included in the delivered body. False for a honeypot, and for anything the owner does not # need to read. Default true: a field that is collected and NOT delivered is the bug this # whole contract was written after. deliver | Bool | default = true, } in let RequiredFieldsExist = std.contract.from_validator (fun spec => let names = spec.fields |> std.array.map (fun f => f.name) in let missing = spec.required |> std.array.filter (fun r => !(std.array.elem r names)) in if std.array.length missing == 0 then 'Ok else 'Error { message = "`required` names fields that are not declared: " ++ std.string.join ", " missing, notes = ["A required field the form never renders can never be filled in."], } ) in # Nothing declared twice: two entries for one field name is not a form, it is a race between # whichever the handler reads last. let FieldNamesUnique = std.contract.from_validator (fun spec => let names = spec.fields |> std.array.map (fun f => f.name) in let dups = names |> std.array.filter (fun n => (names |> std.array.filter (fun m => m == n) |> std.array.length) > 1) in if std.array.length dups == 0 then 'Ok else 'Error { message = "duplicate field name: " ++ std.string.join ", " dups } ) in # The recipient is an ENV VAR NAME, never an address. An address in a config file is an address # in git, and an address in git is an address in a scraper's list. let RecipientIsEnvVar = std.contract.from_validator (fun v => if std.string.is_match "^[A-Z][A-Z0-9_]*$" v then 'Ok else 'Error { message = "`deliver_to` must be an ENV VAR NAME (e.g. WORK_REQUEST_RECIPIENT), got: " ++ v, notes = ["A literal address here is a literal address in the repository."], } ) in let form_spec_type = { # Matches the URL: POST /api/htmx/form/, and the file name `.ncl`. id | String, # The env var holding the destination address. Unset at runtime ⇒ the submission is logged and # the visitor still gets a confirmation: a form that 500s because the OWNER forgot to configure # it punishes the wrong person. deliver_to | String | RecipientIsEnvVar, # The subject line. `{field}` interpolates a submitted value; an unknown field interpolates to # nothing rather than failing, because a subject line is not worth losing a lead over. subject | String | default = "", # Fluent keys for the two response fragments the handler returns. success_key | String | default = "", error_key | String | default = "", fields | Array field_type, required | Array String | default = [], } in { FieldKind = field_kind, Field = field_type, FormSpec = std.contract.Sequence [ form_spec_type, RequiredFieldsExist, FieldNamesUnique, ], # Build a spec. `required` defaults to every field marked `required = true`, so the two cannot # disagree — an earlier design had them as independent lists and that is a contradiction waiting. make_form = fun data => # The contract must be applied to each field BEFORE reading `required` off it — a raw record # carries no defaults, and `f.required` on one is a missing field, not `false`. let flds = data.fields |> std.array.map (fun f => field_type & f) in let req = flds |> std.array.filter (fun f => f.required) |> std.array.map (fun f => f.name) in # `data` must lose its own `fields` before the merge: `data & { fields = }` is a record referring to itself, and Nickel calls that what it is. ((std.record.remove "fields" data) & { fields = flds, required = req }) | (std.contract.Sequence [form_spec_type, RequiredFieldsExist, FieldNamesUnique]), }