|
;;; json-schema-form.el --- JSON Schema forms using vui.el -*- lexical-binding: t; -*- |
|
|
|
;; Copyright (C) 2026 Boris Buliga |
|
;; SPDX-License-Identifier: GPL-3.0-or-later |
|
|
|
;; Author: Boris Buliga <boris@d12frosted.io> |
|
;; URL: https://github.com/d12frosted/json-schema-form |
|
;; Version: 0.1.0 |
|
;; Package-Requires: ((emacs "29.1") (vui "1.0.0")) |
|
;; Keywords: ui, forms, json |
|
|
|
;;; Commentary: |
|
|
|
;; Generate Emacs forms from JSON Schema using vui.el. |
|
;; |
|
;; This package provides a declarative way to render JSON Schema as |
|
;; interactive forms in Emacs buffers. Built on vui.el, it handles |
|
;; state management, validation, and lifecycle automatically. |
|
;; |
|
;; Usage: |
|
;; (jsf-render schema :on-submit (lambda (data) (message "%S" data))) |
|
;; |
|
;; Or mount inline in any buffer: |
|
;; (jsf-insert schema :on-submit callback) |
|
|
|
;;; Code: |
|
|
|
(require 'vui) |
|
(require 'json) |
|
(require 'cl-lib) |
|
|
|
;;; Custom Variables |
|
|
|
(defgroup json-schema-form nil |
|
"JSON Schema forms using vui.el." |
|
:group 'vui |
|
:prefix "jsf-") |
|
|
|
(defcustom jsf-field-width 40 |
|
"Default width for text input fields." |
|
:type 'integer |
|
:group 'json-schema-form) |
|
|
|
(defface jsf-error-face |
|
'((t :inherit error)) |
|
"Face for validation error messages." |
|
:group 'json-schema-form) |
|
|
|
(defface jsf-label-face |
|
'((t :inherit bold)) |
|
"Face for field labels." |
|
:group 'json-schema-form) |
|
|
|
(defface jsf-required-face |
|
'((t :inherit font-lock-warning-face)) |
|
"Face for required field indicator." |
|
:group 'json-schema-form) |
|
|
|
;;; Schema Utilities |
|
|
|
(defun jsf--get (schema key &optional default) |
|
"Get KEY from SCHEMA alist, or DEFAULT." |
|
(if-let* ((cell (assq key schema))) |
|
(cdr cell) |
|
default)) |
|
|
|
(defun jsf--schema-type (schema) |
|
"Return the type of SCHEMA, inferring if necessary." |
|
(let ((type (jsf--get schema :type))) |
|
(cond |
|
((stringp type) type) |
|
((jsf--get schema :enum) "string") |
|
((jsf--get schema :properties) "object") |
|
((jsf--get schema :items) "array") |
|
(t "string")))) |
|
|
|
(defun jsf--schema-title (schema &optional path) |
|
"Get display title for SCHEMA, using PATH as fallback." |
|
(or (jsf--get schema :title) |
|
(jsf--get schema :description) |
|
(and path (car (last path))) |
|
"field")) |
|
|
|
(defun jsf--required-p (prop-name required-list) |
|
"Return non-nil if PROP-NAME is in REQUIRED-LIST." |
|
(member prop-name required-list)) |
|
|
|
;;; Validation |
|
|
|
(defun jsf--validate-string (value schema) |
|
"Validate string VALUE against SCHEMA. Return error message or nil." |
|
(let ((minlen (jsf--get schema :minLength)) |
|
(maxlen (jsf--get schema :maxLength)) |
|
(pattern (jsf--get schema :pattern)) |
|
(len (length (or value "")))) |
|
(cond |
|
((and minlen (< len minlen)) |
|
(format "Minimum length is %d" minlen)) |
|
((and maxlen (> len maxlen)) |
|
(format "Maximum length is %d" maxlen)) |
|
((and pattern (not (string-match-p pattern (or value "")))) |
|
"Does not match required pattern")))) |
|
|
|
(defun jsf--validate-number (value schema) |
|
"Validate number VALUE against SCHEMA. Return error message or nil." |
|
(when (numberp value) |
|
(let ((min (jsf--get schema :minimum)) |
|
(max (jsf--get schema :maximum)) |
|
(emin (jsf--get schema :exclusiveMinimum)) |
|
(emax (jsf--get schema :exclusiveMaximum))) |
|
(cond |
|
((and min (< value min)) |
|
(format "Must be >= %s" min)) |
|
((and max (> value max)) |
|
(format "Must be <= %s" max)) |
|
((and emin (<= value emin)) |
|
(format "Must be > %s" emin)) |
|
((and emax (>= value emax)) |
|
(format "Must be < %s" emax)))))) |
|
|
|
(defun jsf--parse-number (s) |
|
"Parse S as a number. Return (ok . value) cons." |
|
(let ((str (string-trim (or s "")))) |
|
(if (string-empty-p str) |
|
(cons nil nil) |
|
(condition-case nil |
|
(let ((val (car (read-from-string str)))) |
|
(if (numberp val) |
|
(cons t val) |
|
(cons nil nil))) |
|
(error (cons nil nil)))))) |
|
|
|
(defun jsf--parse-integer (s) |
|
"Parse S as an integer. Return (ok . value) cons." |
|
(let ((result (jsf--parse-number s))) |
|
(if (and (car result) (integerp (cdr result))) |
|
result |
|
(cons nil nil)))) |
|
|
|
(defun jsf--validate-field (value schema required) |
|
"Validate VALUE against SCHEMA. REQUIRED if non-nil. |
|
Return error message or nil." |
|
(let ((type (jsf--schema-type schema))) |
|
(cond |
|
;; Required check |
|
((and required |
|
(or (null value) |
|
(and (stringp value) (string-empty-p (string-trim value))))) |
|
"Required") |
|
;; Type-specific validation |
|
((string= type "string") |
|
(jsf--validate-string value schema)) |
|
((string= type "integer") |
|
(let ((parsed (jsf--parse-integer value))) |
|
(cond |
|
((and (not (string-empty-p (or value ""))) (not (car parsed))) |
|
"Must be an integer") |
|
((car parsed) |
|
(jsf--validate-number (cdr parsed) schema))))) |
|
((string= type "number") |
|
(let ((parsed (jsf--parse-number value))) |
|
(cond |
|
((and (not (string-empty-p (or value ""))) (not (car parsed))) |
|
"Must be a number") |
|
((car parsed) |
|
(jsf--validate-number (cdr parsed) schema)))))))) |
|
|
|
(defun jsf--validate-schema (schema values) |
|
"Validate VALUES against SCHEMA. Return alist of (path . error) or nil." |
|
(let ((errors nil)) |
|
(when (string= (jsf--schema-type schema) "object") |
|
(let ((props (jsf--get schema :properties)) |
|
(required (jsf--get schema :required))) |
|
(dolist (prop props) |
|
(let* ((name (car prop)) |
|
(subschema (cdr prop)) |
|
(value (plist-get values (intern (concat ":" name)))) |
|
(req (jsf--required-p name required)) |
|
(err (jsf--validate-field value subschema req))) |
|
(when err |
|
(push (cons name err) errors)))))) |
|
(nreverse errors))) |
|
|
|
;;; Value Conversion |
|
|
|
(defun jsf--convert-value (value schema) |
|
"Convert VALUE according to SCHEMA type for output." |
|
(let ((type (jsf--schema-type schema))) |
|
(pcase type |
|
("integer" (cdr (jsf--parse-integer value))) |
|
("number" (cdr (jsf--parse-number value))) |
|
("boolean" (if value t :false)) |
|
("string" (string-trim (or value ""))) |
|
(_ value)))) |
|
|
|
(defun jsf--gather-values (schema values) |
|
"Convert VALUES to proper types according to SCHEMA for JSON output." |
|
(let ((result nil)) |
|
(when (string= (jsf--schema-type schema) "object") |
|
(let ((props (jsf--get schema :properties))) |
|
(dolist (prop props) |
|
(let* ((name (car prop)) |
|
(subschema (cdr prop)) |
|
(key (intern (concat ":" name))) |
|
(value (plist-get values key)) |
|
(converted (jsf--convert-value value subschema))) |
|
(push converted result) |
|
(push key result))))) |
|
result)) |
|
|
|
;;; Components |
|
|
|
(vui-defcomponent jsf-field-error (error) |
|
"Display validation error message." |
|
:render |
|
(when error |
|
(vui-text error :face 'jsf-error-face))) |
|
|
|
(vui-defcomponent jsf-string-field (name schema value required on-change) |
|
"Render a string field with optional enum support." |
|
:render |
|
(let ((enum (jsf--get schema :enum))) |
|
(if enum |
|
;; Dropdown for enum |
|
(vui-select |
|
:value (or value (car enum)) |
|
:options (mapcar (lambda (opt) (cons opt opt)) enum) |
|
:on-change on-change) |
|
;; Regular text field |
|
(vui-field |
|
:value (or value "") |
|
:size jsf-field-width |
|
:on-change on-change)))) |
|
|
|
(vui-defcomponent jsf-number-field (name schema value required on-change) |
|
"Render a number/integer field." |
|
:render |
|
(vui-field |
|
:value (if value (format "%s" value) "") |
|
:size 15 |
|
:on-change on-change)) |
|
|
|
(vui-defcomponent jsf-boolean-field (name schema value on-change) |
|
"Render a boolean checkbox." |
|
:render |
|
(vui-checkbox |
|
:checked value |
|
:on-change on-change)) |
|
|
|
(vui-defcomponent jsf-array-field (name schema value on-change) |
|
"Render an array field with add/remove support." |
|
:state ((items (or value []))) |
|
|
|
:render |
|
(let* ((add-item (lambda () |
|
(let ((new-items (vconcat items [""]))) |
|
(vui-set-state :items new-items) |
|
(funcall on-change new-items)))) |
|
(remove-item (lambda (idx) |
|
(let ((new-items (vconcat |
|
(seq-into (seq-subseq items 0 idx) 'list) |
|
(seq-into (seq-subseq items (1+ idx)) 'list)))) |
|
(vui-set-state :items new-items) |
|
(funcall on-change new-items)))) |
|
(update-item (lambda (idx val) |
|
(let ((new-items (copy-sequence items))) |
|
(aset new-items idx val) |
|
(vui-set-state :items new-items) |
|
(funcall on-change new-items))))) |
|
(vui-vstack |
|
;; Existing items |
|
(when (> (length items) 0) |
|
(apply #'vui-fragment |
|
(seq-into |
|
(seq-map-indexed |
|
(lambda (item idx) |
|
(vui-hstack |
|
:key idx |
|
(vui-field |
|
:value (or item "") |
|
:size 30 |
|
:on-change (lambda (v) (funcall update-item idx v))) |
|
(vui-space 1) |
|
(vui-button "×" |
|
:on-click (lambda () (funcall remove-item idx))))) |
|
items) |
|
'list))) |
|
;; Add button |
|
(vui-button "+ Add" |
|
:on-click add-item)))) |
|
|
|
(defcustom jsf-label-width 12 |
|
"Width for field labels to ensure alignment." |
|
:type 'integer |
|
:group 'json-schema-form) |
|
|
|
(vui-defcomponent jsf-property (name schema value required error on-change) |
|
"Render a single property with label and field." |
|
:render |
|
(let* ((title (jsf--schema-title schema (list name))) |
|
(type (jsf--schema-type schema)) |
|
(label (vui-box |
|
(vui-hstack |
|
(vui-text (format "%s:" title) :face 'jsf-label-face) |
|
(if required |
|
(vui-text " *" :face 'jsf-required-face) |
|
(vui-text ""))) |
|
:width jsf-label-width)) |
|
(field (pcase type |
|
("boolean" |
|
(vui-component 'jsf-boolean-field |
|
:name name :schema schema :value value :on-change on-change)) |
|
((or "integer" "number") |
|
(vui-component 'jsf-number-field |
|
:name name :schema schema :value value :required required |
|
:on-change on-change)) |
|
("array" |
|
(vui-component 'jsf-array-field |
|
:name name :schema schema :value value :on-change on-change)) |
|
(_ |
|
(vui-component 'jsf-string-field |
|
:name name :schema schema :value value :required required |
|
:on-change on-change))))) |
|
;; Arrays get label on separate line, others inline |
|
(if (string= type "array") |
|
(if error |
|
(vui-vstack |
|
label |
|
(vui-box field :padding-left 2) |
|
(vui-text error :face 'jsf-error-face)) |
|
(vui-vstack |
|
label |
|
(vui-box field :padding-left 2))) |
|
(if error |
|
(vui-vstack |
|
(vui-hstack label field) |
|
(vui-text error :face 'jsf-error-face)) |
|
(vui-hstack label field))))) |
|
|
|
(vui-defcomponent jsf-form (schema on-submit on-cancel) |
|
"Main form component that renders a JSON schema." |
|
:state ((values nil) |
|
(errors nil) |
|
(submitted nil)) |
|
|
|
:render |
|
(let* ((title (jsf--get schema :title "Form")) |
|
(props (jsf--get schema :properties)) |
|
(required (jsf--get schema :required)) |
|
(prop-names (mapcar #'car props)) |
|
(get-value (lambda (name) |
|
(plist-get values (intern (concat ":" name))))) |
|
(set-value (lambda (name val) |
|
(let ((key (intern (concat ":" name)))) |
|
(vui-set-state :values (plist-put (copy-sequence values) key val))))) |
|
(get-error (lambda (name) |
|
(cdr (assoc name errors)))) |
|
(do-submit (lambda () |
|
(let ((errs (jsf--validate-schema schema values))) |
|
(if errs |
|
(vui-set-state :errors errs) |
|
(vui-batch |
|
(vui-set-state :errors nil) |
|
(vui-set-state :submitted t)) |
|
(when on-submit |
|
(funcall on-submit (jsf--gather-values schema values))))))) |
|
(property-fields |
|
(mapcar |
|
(lambda (name) |
|
(let ((subschema (cdr (assoc name props)))) |
|
(vui-component 'jsf-property |
|
:key name |
|
:name name |
|
:schema subschema |
|
:value (funcall get-value name) |
|
:required (jsf--required-p name required) |
|
:error (funcall get-error name) |
|
:on-change (lambda (v) (funcall set-value name v))))) |
|
prop-names))) |
|
|
|
(if submitted |
|
;; Success state |
|
(vui-vstack |
|
(vui-text "Form submitted successfully!" :face 'success) |
|
(vui-newline) |
|
(vui-button "Submit Another" |
|
:on-click (lambda () |
|
(vui-batch |
|
(vui-set-state :values nil) |
|
(vui-set-state :errors nil) |
|
(vui-set-state :submitted nil))))) |
|
;; Form |
|
(vui-vstack |
|
:spacing 1 |
|
(vui-text title :face 'bold) |
|
(vui-text (make-string (min 40 (length title)) ?─)) |
|
(apply #'vui-vstack property-fields) |
|
(vui-hstack |
|
:spacing 2 |
|
(when on-cancel |
|
(vui-button "Cancel" :on-click on-cancel)) |
|
(vui-button "Submit" :on-click do-submit)))))) |
|
|
|
;;; Public API |
|
|
|
;;;###autoload |
|
(defun jsf-render (schema &rest args) |
|
"Render SCHEMA as a form in a new buffer. |
|
ARGS is a plist with: |
|
:on-submit - callback receiving the form data |
|
:on-cancel - callback when cancelled |
|
:buffer-name - name for the buffer (default \"*JSON Schema Form*\")" |
|
(let ((on-submit (plist-get args :on-submit)) |
|
(on-cancel (plist-get args :on-cancel)) |
|
(buffer-name (or (plist-get args :buffer-name) "*JSON Schema Form*"))) |
|
(vui-mount |
|
(vui-component 'jsf-form |
|
:schema schema |
|
:on-submit on-submit |
|
:on-cancel (or on-cancel |
|
(lambda () (kill-buffer (current-buffer))))) |
|
buffer-name))) |
|
|
|
;;;###autoload |
|
(defun jsf-demo () |
|
"Show a demo form." |
|
(interactive) |
|
(jsf-render |
|
'((:type . "object") |
|
(:title . "Demo Form") |
|
(:required . ("name" "age")) |
|
(:properties |
|
. (("name" . ((:type . "string") |
|
(:title . "Name") |
|
(:minLength . 1) |
|
(:maxLength . 30))) |
|
("age" . ((:type . "integer") |
|
(:title . "Age") |
|
(:minimum . 0) |
|
(:maximum . 130))) |
|
("email" . ((:type . "string") |
|
(:title . "Email") |
|
(:pattern . "^[^@]+@[^@]+\\.[^@]+$"))) |
|
("role" . ((:type . "string") |
|
(:title . "Role") |
|
(:enum . ("user" "admin" "operator")))) |
|
("enabled" . ((:type . "boolean") |
|
(:title . "Enabled"))) |
|
("tags" . ((:type . "array") |
|
(:title . "Tags") |
|
(:items . ((:type . "string")))))))) |
|
:on-submit (lambda (data) |
|
(message "Submitted: %S" data)))) |
|
|
|
(provide 'json-schema-form) |
|
;;; json-schema-form.el ends here |