A MyST plugin providing a {jsonform} directive that
renders an interactive form from a JSON Schema, using
JSONForms.
Add the plugin to your myst.yml:
project:
plugins:
- https://cdn.jsdelivr.net/gh/myst-contrib/myst-jsonform@main/dist/jsonform.mjsThe body can be written in one of two modes (and in each case, using either YAML or JSON syntax).
The whole body is the JSON Schema. JSONForms auto-generates the layout.
```{jsonform}
type: object
properties:
name:
type: string
title: Your name
age:
type: integer
title: Age
minimum: 0
```If the body has a top-level Schema: key, it is read as a set of named chunks.
This unlocks a custom layout, initial data, and styling. Two families of keys:
JSONForms-native — these mirror the three tabs on the jsonforms.io examples:
| key | supported by | required | meaning |
|---|---|---|---|
Schema |
jsonforms.io | yes | the JSON Schema |
UISchema |
jsonforms.io | no, auto-generated if omitted | the UI Schema (layout); |
Data |
jsonforms.io | no | initial form data |
Style |
this plugin | no | per-form CSS — see Styling |
Submit |
this plugin | no, defaults to print |
what happens on submit — see Submit actions |
Key names are matched case-insensitively and ignoring spaces/underscores, so
UISchema, UI Schema and ui_schema are equivalent.
```{jsonform}
Schema:
type: object
properties:
firstName: {type: string, title: First name}
lastName: {type: string, title: Last name}
UISchema:
type: HorizontalLayout
elements:
- {type: Control, scope: "#/properties/firstName"}
- {type: Control, scope: "#/properties/lastName"}
Data:
firstName: Ada
```From a bare schema, JSONForms renders:
- strings (with
formatforemail,uri,date,date-time,time) - integers / numbers (with
minimum/maximum) - booleans (checkboxes)
enum(dropdowns), and arrays ofenum(multi-select checkboxes)- arrays of objects (add/remove tables)
requiredfields and live validation (the Submit button stays disabled until the form is valid)
A bare schema does not expand nested object properties — JSONForms'
auto-generated layout only renders the top-level fields. To render nested
objects, use Mode 2 and provide a UISchema whose controls point at the nested
scopes (typically inside a Group):
```{jsonform}
Schema:
type: object
properties:
name: {type: string, title: Name}
address:
type: object
properties:
street: {type: string, title: Street}
city: {type: string, title: City}
UISchema:
type: VerticalLayout
elements:
- {type: Control, scope: "#/properties/name"}
- type: Group
label: Address
elements:
- {type: Control, scope: "#/properties/address/properties/street"}
- {type: Control, scope: "#/properties/address/properties/city"}
```The form renders inside an open shadow root, so it is style-isolated from the page. There are three ways to customise it:
-
Theme variables (site-wide). The base stylesheet exposes CSS custom properties —
--jsonform-accent,--jsonform-accent-hover,--jsonform-border,--jsonform-radius,--jsonform-muted,--jsonform-error,--jsonform-gap,--jsonform-max-width, … Because inherited properties cross the shadow boundary, you can override them with ordinary site CSS::root { --jsonform-accent: #2e7d32; --jsonform-radius: 10px; }
-
Inline CSS (per form). A
Style:chunk with verbatim rules, injected into that form only:```{jsonform} Schema: type: object properties: {name: {type: string, title: Name}} Style: | .control > label { color: rebeccapurple; } .control input { border: 2px solid rebeccapurple; } ```
-
External stylesheet (per form). A
Style:value that is a path or URL ending in.cssis treated as a reference; mystmd stages the file and links it inside the form's shadow root:Style: _static/style_forms.css
The Submit chunk says what happens when the (validated) form is submitted. It
is either a single action or a list of actions, each with a type. On
submit they run in order; the form reports success only if every action
succeeds, otherwise it shows the first failure.
If Submit is omitted, the default is a single print action.
Shows the collected data as JSON below the form (the default behaviour). Always succeeds.
Submit:
type: printSends the data to an HTTP endpoint with fetch.
| key | default | meaning |
|---|---|---|
url |
(required) | endpoint to send to |
method |
POST |
HTTP method |
json |
false |
content-type: false → text/plain (no CORS preflight), true → application/json |
headers |
(none) | extra request headers, merged over the default Content-Type |
For example:
Submit:
type: webapi
url: https://my-data-collector.example.org/survey- Success = the response status is
2xx. Redirects (3xx) are followed. - CORS: by default the body is sent as
text/plain(still a JSON string), which is a "simple" request and avoids the browser's CORS preflight — so the endpoint only needs to returnAccess-Control-Allow-Origin. Setjson: trueto use the officialapplication/jsoncontent-type, which triggers a preflight (the server must then also handleOPTIONS). All CORS configuration is on the endpoint's server, never the page's.
Formspree
You can easily setup a free endpoint with Formspree or
similar services.
Submit:
type: webapi
url: https://formspree.io/f/xxxxxxxx
json: true
headers:
Accept: application/jsonFormspree needs Accept: application/json, otherwise they
302-redirect to an HTML "thanks" page with no CORS headers, which fails.
Emits DOM CustomEvents so other JavaScript on the page can react to the form.
| key | default | meaning |
|---|---|---|
submit_event |
(none) | event name dispatched when the form is submitted |
change_event |
(none) | event name dispatched on every change (keystroke, etc.) |
name |
(none) | identity, echoed into event.detail.name |
Submit:
type: event
name: survey12
submit_event: done
change_event: ongoingThe event is dispatched with bubbles: true, composed: true, so it crosses the
shadow-DOM boundary and reaches document/window — page code listens
globally, with no handle to the element:
window.addEventListener('done', (e) => {
console.log(e.detail.name, e.detail.data, e.detail.valid);
});
window.addEventListener('ongoing', (e) => {
console.log('changed:', e.detail.data);
});event.detail carries { name, phase, data, valid, errors }, where phase is
'change' or 'submit' and valid is errors.length === 0. event.target is
the form's root element, so a scoped listener with a handle works too.
Event names are global strings: if you create two forms, you can use the same event names and callback handlers, and you can disambiguate via
detail.name.
Note that due to JSONForms internals, thechange_eventif defined also fires once on load.
Note that a single form can choose to trigger multiple actions on submit, e.g.
Submit:
- type: print
- type: webapi
url: https://data-collector.example/survey/12/response
- type: event
submit_event: doneA page exercising the directive (types, layouts, styling) is published at:
and its source is:
The directive emits an anywidget node. The browser-side
widget (dist/widget.mjs) is a self-contained bundle of React + JSONForms, so
there is exactly one copy of React and of @jsonforms/core — no CDN resolution
at view time. The plugin itself (dist/jsonform.mjs) is bundled too (with
js-yaml inlined) so it can be loaded directly from a URL.
Requires bun.
bun install
bun run build # builds dist/widget.mjs and dist/jsonform.mjs
bun run watch # rebuilds the widget on change (for local iteration)For local iteration, point your myst.yml at the source plugin instead of the
URL; it automatically uses the local dist/widget.mjs when present:
project:
plugins:
- /absolute/path/to/myst-jsonform/src/jsonform.mjsThe dist/ bundles are committed to the repository (that is what users load via
jsDelivr). After changing anything under src/:
bun run build
git add dist
git commit -m "rebuild"
git pushjsDelivr serves @main with a cache of up to ~12 h. For reproducible, instantly
pinned releases, tag a version and have users reference it instead of @main
(and bump the WIDGET_URL tag in src/jsonform.mjs to match):
git tag v0.1.0 && git push --tags - https://cdn.jsdelivr.net/gh/flotpython/myst-jsonform@v0.1.0/dist/jsonform.mjs