teia


Teia is a Simple, Fast and Reliable HyperMedia library.




(this space intentionally left almost blank)
 

Components

Components are Teia's most basic building block.

A component contains the following data:

  • :name An identifiable name

  • :template A function that accepts its state and returns the HTML template.

  • :props Its inner state.

  • :components Its inner components, passed by parameters to minimize external state. This is helpful when testing later on. By having all its data from within, it becomes simpler to manipulate it, beit whatever.

(ns j0suetm.teia.component
  (:refer-clojure :exclude [compile]))

Lifetime

The Stateful protocol defines the point on when a component has both props and inner components defined.

build builds a component map, which can be used by compile later. Inner components can be accessed and manipulated just like props, but are separated since they need their own state.

compile compiles component states to itself. Basically renders the given component based on its props, after recursively rendering its inner components.

(defprotocol Stateful
  (build
    [cmp]
    [cmp props]
    [cmp props components])
  (compile
    [cmp]))

Barebones component for failure showcase.

Defined like this because the Component record isn't defined yet.

(def failure-cmp
  {:name :failure
   :template (fn [{:keys [props]}]
               [:div
                [:p (:reason props)]
                (let [ex (:exception props)
                      stacktrace (map str (.getStackTrace ex))]
                  [:ul
                   [:li [:p (.getMessage ex)]]
                   (for [trace stacktrace]
                     [:li [:p trace]])])])})

Inner components come as a list. Here we reduce them to a map in order for easier retrieval when applying the parent component

TODO: provide better ways to handle exceptions. Enable the end user to control it as well.

(defrecord Component [name template]
  Stateful
  (build
    [cmp]
    (build cmp {} []))
  (build
    [cmp props]
    (build cmp props []))
  (build
    [cmp props components]
    (merge cmp {:props props
                :components components}))
  (compile
    [cmp]
    (let [{:keys [name template props components]} cmp]
      (try
        (assoc
         cmp :compiled
         (template
          {:props props
           :components (reduce
                        (fn [cmp-map cmp]
                          (assoc
                           cmp-map
                           (:name cmp)
                           (or (:compiled cmp) cmp)))
                        {} components)}))
        (catch Exception e
          (compile
           (build
            (map->Component failure-cmp)
            {:reason (str "failed to compile component " name)
             :exception e})))))))

Alias to pipe build->compile directly. Makes life easier when building a component from within another one.

(defn $
  ([cmp] ($ cmp {} []))
  ([cmp props] ($ cmp props []))
  ([cmp props components]
   (:compiled
    (compile
     (build cmp props components)))))
(defn component?
  [data]
  (instance? Component data))
 

Teia is a Simple, Fast and Reliable HyperMedia library.

(ns j0suetm.teia.core)

Helper macro that defines a component.

Simply wraps j0suetm.teia.component/->Component.

(defmacro defcmp
  [cmp-name args body]
  `(j0suetm.teia.component/->Component
    (keyword '~cmp-name)
    (fn ~args
      ~@body)))
 
(ns j0suetm.teia.html)
(defn populate
  [tree])
 

Routing

Teia eases the process of routing a component. Besides having this as its priority, it still offers support for other purposes, like a application/edn router.

(ns j0suetm.teia.router
  (:require
   [hiccup2.core :as hiccup]
   [j0suetm.teia.component :as teia.cmp]
   [malli.util]
   [muuntaja.core :as muuntaja]
   [muuntaja.format.core :as muuntaja.fmt]
   [reitit.coercion.malli :as reitit.coercion.malli]
   [reitit.dev.pretty :as reitit.pretty]
   [reitit.ring :as reitit]
   [reitit.ring.coercion :as reitit.coercion]
   [reitit.ring.middleware.exception :as reitit.mddwr.ex]
   [reitit.ring.middleware.muuntaja :as reitit.mddwr.muuntaja]
   [reitit.ring.middleware.parameters :as reitit.mddwr.params])
  (:import
   [java.io OutputStream]))

Muuntaja encoder that renders a given component through hiccup.

See: https://github.com/metosin/muuntaja/blob/master/modules/muuntaja/src/muuntaja/format/json.clj#L39

(def component->html-encoder
  (reify
    muuntaja.fmt/EncodeToBytes
    (encode-to-bytes [_ data charset]
      (-> (if (teia.cmp/component? data)
            (:compiled (teia.cmp/compile data))
            data)
          (hiccup/html)
          (str)
          (.getBytes ^String charset)))
    muuntaja.fmt/EncodeToOutputStream
    (encode-to-output-stream [_ data charset]
      (fn [^OutputStream output-stream]
        (.write
         output-stream
         (-> (if (teia.cmp/component? data)
               (:compiled (teia.cmp/compile data))
               data)
             (hiccup/html)
             (str)
             (.getBytes ^String charset)))))))

Muuntaja format for text/html

(def html-format
  (let [enc-fn (fn [_]
                 component->html-encoder)]
    (muuntaja.fmt/map->Format
     {:name "text/html"
      :encoder [enc-fn]})))

Route

Teia uses reitit in its back-end. So you can define routes the same way you have been doing with reitit, while also being able to define component routes in-between the other routes. Teia will parse them for you, and re-arrange the entire route tree, with reitit routes wrapping your pre-defined component routes.

For example, a sample component route defined like this:

will be adapted to:

which renders your component, using the states from the handler function.

(comment
  ["/greet/:name"
   {:get
    {:component (teia.cmp/->Component
                 :greeting
                 (fn [{:keys [props]}]
                   [:p (str (:greeting props) ", "
                            (:name props) "!")]))
     :parameters {:path {:name :string}
                  :headers {:greeting :string}}
     :handler (fn [{:keys [path-params headers]}]
                {:status 200
                 :props (merge path-params headers)})}}]
  ["/greet/:name"
   {:get
    {:parameters {:path {:name :string}
                  :headers {:greeting :string}}
     :handler 'j0suetm.teia.router/component-route-handler
     }}]
  )

Generic reitit handler that uses the return from the defined handler as the state to render a route's component.

The handler's response should be a map containing the props and the inner components be used by the to-be-built component.

(defn component-route-handler
  [handler component request]
  (try
    (let [{:keys [status props components]
           :or {status 200
                props {}
                components []}} (handler request)]
      {:status status
       :body (teia.cmp/compile
              (teia.cmp/build component props components))})
    (catch Exception e
      {:status 500
       :body (teia.cmp/build
              (teia.cmp/map->Component teia.cmp/failure-cmp)
              {:reason "failed to handle component route"
               :exception e})})))

Adapts a component route to a reitit router.

Recursively applies itself to any sibling route.

(defn component-route->reitit-route
  [uri methods & sibling-routes]
  (into
   [uri (reduce-kv
         (fn [methods method
              {:keys [handler component]
               :as definition}]
           (let [handler' (partial component-route-handler
                                   handler component)]
             (assoc methods method
                    (if component
                      (-> (assoc definition :handler handler')
                          (dissoc :component))
                      definition))))
         {} methods)]
   (mapv
    (partial apply component-route->reitit-route)
    sibling-routes)))

Builds a reitit router, adapting component routes to reitit routes through the way.

Options:

  • :default-format The response data format. UI router, i.e text/html by default.

No need to verify response when UI router.

(defn build
  [routes & [options]]
  (let [{:keys [default-format]
         :or {default-format "text/html"}} options
        ui-router? (= default-format "text/html")
        coercion (reitit.coercion.malli/create
                  {:error-keys #{:type :in :value :errors
                                 :humanized :schema}
                   :compile malli.util/closed-schema
                   :strip-extra-keys true
                   :default-values true
                   :options nil})
        muuntaja (muuntaja/create
                  (-> (assoc-in
                       muuntaja/default-options
                       [:formats "text/html"]
                       html-format)
                      (assoc :default-format default-format)))
        middlewares (into
                     [reitit.mddwr.ex/exception-middleware
                      reitit.mddwr.muuntaja/format-negotiate-middleware
                      reitit.mddwr.muuntaja/format-request-middleware
                      reitit.mddwr.muuntaja/format-response-middleware
                      reitit.coercion/coerce-request-middleware
                      reitit.mddwr.params/parameters-middleware]
                     (when ui-router?
                       [reitit.coercion/coerce-response-middleware]))]
    (reitit/router
     (map #(apply component-route->reitit-route %) routes)
     {:exception reitit.pretty/exception
      :data {:coercion coercion
             :muuntaja muuntaja
             :middleware middlewares}})))