This is the beautiful red bandana to the luxuriant handlebar mustache that is Chassis. html.yeah
provides a (totally optional) convenience
for defining Chassis alias elements in terms of an attribute
schema. Schema syntax follows malli vector syntax (though malli itself is not a dependency of this library). Peep this smoke show:
(require '[dev.onionpancakes.chassis.core :as c])
(require '[html.yeah :as ya :refer [defelem children]])
;;; Server components the way nature intended
(defelem daisy-button
[:maybe {:doc "A robust and correct DaisyUI button"
:keys [color style behavior size modifier]
:or {color :primary}}
[:map
[:color {:optional true} Color] ;;; [:enum :neutral :accent :primary]
[:style {:optional true} Style]
[:behavior {:optional true} Behavior]
[:size {:optional true} Size]
[:modifier {:optional true} Modifier]]]
[:button {:class [(colors color)
(styles style)
(behaviors behavior)
(sizes size)
(modifiers modifier)]}
(children)])
;;; Inspect it brother
(ya/attributes 'daisy-button) ; => get the attached malli schema
;;; Render it brother
(c/html [daisy-button {:color :neutral} "Html Yeah Brother"])
(c/html [::daisy-button {:color :netural} "Keywords work too!"])
See dev.clj or yeah_test.clj for more examples.
I want:
- To define custom html elements explicitly in terms of their attributes and children
- To use Chassis for its speed and flexibility
- An excellent development/repl experience. Never be lost wondering about attributes and their types
- To easily support attribute transformation - i.e Clojure data structures in, safe html out
- To easily support extension via compile time schema transformations
- The option to do runtime checks on attributes and children
- The option to generate sample elements conforming to a schema
- To be able to ignore this library entirely in a broader Chassis application
This is literally the only thing html.yeah
brings to the table brother. It attaches a malli schema to a chassis alias element.
(ns dev
(:require [html.yeah :as yeah :refer [defelem children]]))
(defelem simple-button
[:map {:doc "A simple button with a schema for type"
:as attrs}
[:type [:enum :submit :button]]]
[:button (update attrs :type name)
(children)])
Notice the traditional property syntax of a malli schema is extended to support associative destructuring. Destructuring forms are stripped from schema metadata.
Note: These bindings cannot be used in the schema itself, just the element body.
Given an element's symbol, you can inspect its attribute schema from the REPL:
(yeah/attributes 'simple-button) ; => [:map [:type [:enum :submit :button]]]
You can look at ALL metadata for the element via describe
:
(yeah/describe 'simple-button)
; => {:html.yeah/attributes [map...], :html.yeah/children [:* :any]}
If you evaluate the var you will get the namespace qualified tag used by Chassis:
simple-button ; => :dev/simple-button
The html.yeah/children
macro is used as a generic placeholder for the children passed
to the alias element.
The above simple-button
element is equivalent to the following:
(ns dev
(:require [dev.onionpancakes.chass.core :as c]
[dev.onionpancakes.chassis.compiler :as cc]))
(defmethod c/resolve-alias ::simple-button
[_ attrs children]
(cc/compile
[:button (update attrs :type name) children]))
If one of the binding forms: let
, when-some
, when-let
, if-some
, or if-let
are encountered as the first form
in the element's body, those bindings will be hoisted above the compile.
(defelem let-button
[:map {:keys [type]}
[:type [:enum :submit :button]]]
(let [as-string (name type)]
[:button {:type as-string}
(children)]))
;;; Produced alias element
(defmethod c/resolve-alias ::let-button
[_ attrs children]
(let [{:keys [type]} attrs
as-string (name type)]
(cc/compile
[:button {:type as-string}
children])))
In the case of if-let
and if-some
, both branches of the expression will be compiled:
(defelem if-some-button
[:map {:as attrs}
[:some-type [:enum :button]]
[:nil-type [:enum :submit]]]
(if-some [type (:some-type attrs)]
[:button {:type type} (children)]
[:button {:type (:nil-type attrs)} (children)]))
;;; Produced alias element
(defmethod c/resolve-alias ::if-some-button
[_ attrs children]
(if-some [type (:some-type attrs)]
(cc/compile
[:button {:type type} children])
(cc/compile
[:button {:type (:nil-type attrs)} children])))
The attribute schema is the focal point of defelem
elements.
(defelem unordered-list
[:map {:keys [class]
:or {class []}
:as attrs}
[:class {:optional true} [:vector :string]]]
(let [classes (into ["p-4"] class)]
[:ul (assoc attrs :class classes)
(children)]))
The above element specifies an unordered list element supporting a :class
attribute as a vector of strings.
We can provide a schema property of :html.yeah/children
to specify a schema for child elements:
(defelem unordered-list
[:map {:html.yeah/children [:vector {:min 1} [:tuple [:enum :li] :string]]
:keys [class]
:or {class []}
:as attrs}
[:class {:optional true} [:vector :string]]]
(let [classes (into ["p-4"] class)]
[:ul (assoc attrs :class classes)
(children)]))
The shape of (children)
mirrors whatever content is passed to the Chassis alias element. The underlying render function of the element, will have :malli/schema
metadata added:
{:malli/schema [:=> [:cat ~attr-schema ~child-schema] :any]}
This ensures render functions are picked up by malli.instrument
and malli.dev
.
html.yeah
supports extending elements 2 different ways: attribute options and properties. Attribute options are used to affect attribute transformation, and properties affect schema transformation. Both occur at compile time.
Attribute options are used to control the forms (if any) produced for runtime transformation of attributes. A typical case for this might be converting an attribute that is a Clojure map into a json string. Or perhaps converting normal Clojure forms into some form of expression language in html.
Attribute options are defined on the multimethod html.yeah.attrs/option
. Once an implementation is defined this way, the attribute key can be used in the attribute schema's property map.
Consider the bundled :html.yeah.attrs/transform
option:
(ns html.yeah.attrs)
(defmethod option ::transform
[attr forms tag schema value]
(cons `(~value ~tag) forms))
All attribute forms will be part of a (->>)
thread last expression where the tail argument is assumed to be the attribute map.
(ns dev
(:require [charred.api :as json]
[html.yeah.attrs :as attrs]))
(defn transform
"attrs in tail position"
[tag attrs]
(if (map? (:data-signals attrs))
(update attrs :data-signals json/write-json-str)
attrs))
(defelem signal-div
[:map {::attrs/transform transform ;;; Use a var if using a multimethod - i.e #'transform
:as attrs}]
[:div attrs (children)])
We can get pretty creative. Here is an example of creating an element that supports Alpine.js with Clojure maps for state and ClojureScript forms instead of string based JavaScript:
(ns dev
(:require [charred.api :as json]
[clojure.string :as string]
[html.yeah :refer [defelem]]
[html.yeah.attrs :as attrs]
[squint.compiler :as compiler]))
(defn js
[body]
(let [s (pr-str body)
compiled (compiler/compile-string* s {:elide-imports true
:core-alias "$squint"
:elide-exports true})]
(string/trim
(:body compiled))))
(defmethod attrs/option ::alpinejs
[_ forms _ _ value]
(if-not value
forms
(cons `((fn [attrs#]
(cond-> attrs#
(some? (:x-data attrs#)) (update :x-data json/write-json-str)
(seq? (attrs# "@click")) (update "@click" js)
(seq? (:x-init attrs#)) (update :x-init js)))) forms)))
(defelem alpine-element
[:map {::alpinejs true
:as attrs}
["@click" [:sequential :any]]]
[:div attrs (children)])
[alpine-element {:x-data {:name "turjan"}
"@click" '(js/alert name)
:x-init '(set! name "otherguy")}
"Click me"]
Properties don't work with forms, but still affect compile time. Properties are intended to support augmenting an element's schema. Define a new property via the html.yeah/property
multimethod. It should return a new (vector based) schema.
We can create userland inheritance with a custom merge property:
(ns dev
(:require [html.yeah :as yeah]
[malli.util :as mu]))
(defmethod yeah/property ::merge [_ schema element-syms]
(m/form
(->> (mapv yeah/attributes element-syms)
(reduce
(fn [result target]
(mu/merge target result)) schema))))
(defelem alpine-button
[:map {::merge [alpine-element]
:as attrs}
["@click" :string] ;;; left to right merging of attribute schemas
[:type [:enum :button :submit]]]
[:button attrs (children)])
alpine-button
now inherits all schema fields from alpine-element
as well as the ::alpinejs
attribute option! Html yeah brother.
Since the :doc
schema property automatically translates to :doc
metadata, we can even control inline documentation for an element.
(defmethod yeah/property :doc+
[_ schema doc]
(let [formatted (with-out-str (pprint schema))]
(assoc-in schema [1 :doc]
(-> "Description:\n"
(str "================\n")
(str doc)
(str "\nAttributes:\n")
(str "================\n")
(str formatted)))))
(defelem daisy-button
[:map {:doc+ "A robust and correct DaisyUI button"}])
Now our inline documentation is extra spicy:
defelem
produces a form that is ready to be picked up by malli.dev
.
(ns dev
(:require [malli.dev :as dev]))
(dev/start!) ;;; instruments any defelem elements
Attempting to render an element with invalid attributes or children will produce a very helpful error message:
You can use html.yeah/describe
on an element symbol if you want direct access to the render function for instrumenting purposes.
(html.yeah/describe 'daisy-button) ; => {:html.yeah/render dev/render-daisy-button-html}
Another cool outcome of having a schema handy is the ability to generate valid sample elements from the schema:
(ns dev
(:require [html.yeah :as yeah]
[malli.generator :as mg]))
(defn generate
"We can make custom fun with the fact that a schema is attached to the element"
[symbol & children]
(when-some [s (yeah/attributes symbol)]
[@(resolve symbol) (mg/generate s) children]))
(generate 'daisy-button "Click me") ; => [:dev/daisy-button {:color :neutral :size :xl} "Click me"]