FS FormScript

Rendering on the server

@formscript/render decides which widget a field needs. @formscript/render-dom binds that decision to a browser. @formscript/render-html binds it to a string a server writes.

The two bindings carry the same obligations, so a form behaves the same whichever one writes it: a labelled control for every active field, the field state exposed to the assistive technology, no value without a label, and a failed submit answered with the error summary of §13.4.

The round trip

ts
import { readPostedForm, renderFormHtml } from '@formscript/render-html';

// The request: the body is read against the model of the engine.
const posted = readPostedForm(body, engine);
engine.applyInput(posted.assignments);
const result = engine.submit();

// The response: the form is written again, with the errors where they belong.
const html = renderFormHtml({ document, engine, result, submitted: true });

Nothing in that loop needs JavaScript. A person fills the form in, the browser posts it, the server evaluates it and writes the answer.

A form a server wrote

The form below was written by the server: this page was built by a process that runs the renderer and puts the markup in the page. No browser took part in writing it.

applicant
guarantor

Submit it and the answers land in the address bar, which is the body a server would receive. The working round trip — the server reading the body, running the engine and writing the answer — is the demo server in the repository:

bash
npm run demo:server

It serves the loan application at http://127.0.0.1:4310/, and the form on that page posts back to the same process.

What the markup guarantees

  • Every active field has a labelled control. A field with no label shows its name. A group, a repeat and a radio group carry their label in a <legend>, so the label is never a floating text.
  • The field state is exposed. The control carries aria-required, aria-invalid and aria-describedby; the hint, the help and the error message are the elements it points at.
  • A failed submit answers with the summary. Every failing field is listed at the top of the form, each entry links to its field, and the same message is written inline at the field.
  • A when gate removes the field from the form. The renderer reads the applicability the engine holds, so a field that comes back keeps its place in the document order.
  • The browser does not judge the form. The form carries novalidate, so the only messages a person sees are the ones the specification defines.

The judgement calls

The specification does not say how a server-side renderer binds the intents to HTML. The choices this package makes are recorded in the package README, and the two that matter most are these.

  • The identity of a control is its path. The id of a control is the path the engine addresses the field by, joined with dots: applicant.first_name. The name it posts under is the same path.
  • A repeat is changed by the server. The add and remove buttons of a repeat are submit buttons, not script: they post __fs_repeat, and the server calls mutateRepeat. A form served without JavaScript can still change the shape of a repeat.