Composability and generic routes in Ema 0.8

tldr: Announcing a rewrite of Ema (a static site generator in Haskell with hot reload destined to develop an unique kind of apps) with support for generic route encoders and composability.

I’d like announce version 0.8 of Ema. This version is nearly a total rewrite, 1 aimed at improving Ema towards two ends:

  • Generic routes: automatically derive route encoders and decoders using generics
  • Composability: combine multiple Ema sites to produce a new top-level site.

This post will give a rough overview of the new features, but checkout the official tutorial if you are new to Ema.

Generic routes

Until Ema 0.6, one had to manually write route encoders and decoders. In addition to being tedious, this is an error-prone process. Ema 0.8 introduces the IsRoute typeclass (for route encoding and decoding) that can be generically derived in an inductive manner. A simple TemplateHaskell based API is also provided. What this means is that you can define your routes simply as follows and get encoders and decoders for free:

data Route 
  = Route_Index -- /index.html
  | Route_About -- /about.html
  deriving stock (Show, Eq, Generic)

deriveGeneric ''Route 
deriveIsRoute ''Route [t|'[]|]

The TH function deriveIsRoute will create the IsRoute instance for Route, which in turn provides a routePrism method that returns, effectively, an optics-core Prism' type–specifically, Prism' FilePath Route. This prism can be used to both encode and decode routes to and from the associated file paths, viz.:

ghci> let rp = routePrism @Route ()
ghci> preview rp "about.html"
Just Route_About
ghci> review rp Route_Index
"index.html"

DerivingVia

TemplateHaskell is not strictly required, as you can alternatively use DerivingVia (via GenericRoute), though it is slightly more verbose. The above can equivalently be rewritten as:

import Generic.SOP qualified as SOP

data Route 
  = Route_Index
  | Route_About
  deriving stock (Show, Eq, Generic)
  deriving anyclass (SOP.Generic, SOP.HasDatatypeInfo)
  deriving (HasSubRoutes, HasSubModels, IsRoute) via (GenericRoute Route '[])

The TH approach, however, has better error messages due to use of standalone deriving.

Sub-routes

ADTs are a natural fit to represent route types. Routes can also be nested. For example, here’s the official example of a weblog:

data Route
  = Route_Index
  | Route_About
  | Route_Contact
  | Route_Blog BlogRoute

data BlogRoute
  = BlogRoute_Index
  | BlogRoute_Post Slug

newtype Slug = Slug { unSlug :: String }

Here, BlogRoute is a sub-route of Route. Furthermore, Slug is a sub-route of BlogRoute. The generic deriving mechanism knows how to delegate encoding/decoding to sub-routes.

ghci> let rp = routePrism @BlogRoute ()
ghci> preview rp "post/foo.html"
Just (BlogRoute_Post (Slug "foo"))
ghci> review rp $ BlogRoute_Post (Slug "foo")
"post/foo.html"

Sub-route representation

GenericRoute is designed in such a way as to delegate the generic logic into two different type classes: HasSubRoutes and HasSubModels. This enables customizability of generic behaviour.

The first of these, HasSubRoutes, enables deriving the encoding and decoding behaviour of individual route constructors “via” their coercible types. Let’s consider the typical way to derive IsRoute for BlogRoute above:

-- Let us assume that this is the top-level route in our site.
data BlogRoute
  = BlogRoute_Index
  | BlogRoute_Post Slug
  deriving stock (Show, Eq, Generic)

deriveGeneric ''BlogRoute 
deriveIsRoute ''BlogRoute [t|[]|]

Generic deriving here generates encoder and decoder such that BlogPost_Post "foo" maps to /post/foo.html. That is, the “folder prefix” is determined from the constructor name’s suffix (_Post). This behaviour can be customized using the WithSubRoutes option to GenericRoute thus controlling the semantics of HasSubModels:

data BlogRoute
  = BlogRoute_Index
  | BlogRoute_Post Slug
  deriving stock (Show, Eq, Generic)

deriveGeneric ''Route 
deriveIsRoute ''Route [t|
  '[ WithSubRoutes 
     [ FileRoute "index.html"
     , FolderRoute "blog" Slug
     ]
   ]
  |]

Now, BlogPost_Post "foo" maps to /blog/foo.html; note the distinction between /blog and /post prefix. You can also drop the prefix entirely by using WithSubRoutes [FileRoute "index.html", Slug]. Ema provides FileRoute and FolderRoute (which have an IsRoute instance), but nothing should you stop from writing your own representation types; the only requirement is that they are coercible and the target type has an IsRoute instance.

Route isomorphism checks

If your route prism is unlawful (they fail to satisfy the Prism' laws), Ema will check this at runtime. This is useful when you want to derive IsRoute manually. On the other hand, it is impossible to create unlawful prisms when using only generic deriving (assuming no custom WithSubRoutes encoding).

Composability

Another key feature of Ema 0.8 is that multiple Ema apps can be combined to produce a new top-level site. Emanote, a note-publishing system, is an Ema app. Say, you want to create a personal website using Ema, but want to delegate publishing of your notes to Emanote. You can combine both your Ema app and the Emanote managed site into a single site, by definining a top-level route like this:

import Emanote.Route.SiteRoute.Type qualified as Emanote

data Route = ...  -- My Ema app's route

type TopRoute =
  MultiRoute 
    '[ Route 
     , FolderRoute "notes" Emnote.SiteRoute
     ]

main = do 
  Ema.runSite @TopRoute ...

Alternatively, you may create a top-level ADT instead (this has its own pros and cons):

data TopRoute
  = TopRoute_MyApp Route 
  | TopRoute_Notes Emanote.SiteRoute

deriveGeneric ''TopRoute 
deriveIsRoute ''TopRoute [t|
  '[ WithSubRoutes 
     [ Route -- Override this to drop the /myapp prefix.
     , FolderRoute "notes" Emanote.SiteRoute
     ]
   ]
  |]

Now, your notes are served at path /notes with the rest of the site served from /.

You can view the full code for this pattern here. See the “Multi” and “MultiRoute” examples in Ema source tree.

Dynamic instead of LVar

This release also introduces the Dynamic type to represent type-varying model values. Dynamic’s compose better than LVar’s. The unionmount library, which Emanote uses, has also been updated to be Dynamic-friendly.

Real-world example

See https://github.com/fpindia/fpindia-site (powering https://functionalprogramming.in/) for a real-world example that uses Ema 0.8 (the jobs data is managed using Dynamic). There are more here.

Credits

I greatly appreciate feedback and contributions from the following people in enabling this release:

  • Lucas Viana, for actually using the development branch (multisite) of Ema as it is being developed and thereby influencing much of the design behind generic deriving of routes.
  • Riuga Bachi, for contributing TemplateHaskell support, better compiler error messages, etc.
  • Iceland_jack, dfeuer, K. A. Buhr and others for answering my Haskell questions on Stackoverflow.
  • TheMatten who explained to me a lens-based design for achieving composability (the final design, based on Prism' instead, is not fundamentally different).
  • Isaac Shapira for inspiring me to consider some of the ideas (isomorphism checks, generic deriving, etc.) in this release.

For further information, see the official documentation: https://ema.srid.ca. If you want to get started right away, see https://github.com/EmaApps/ema-template.

Footnotes
1.
The PR spawned commits over nearly 7 months of intermittent work with feedback and contributions from others.
#blog/ema