{-# LANGUAGE AllowAmbiguousTypes #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE InstanceSigs #-} {-# LANGUAGE MultiParamTypeClasses #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeFamilies #-} {-# LANGUAGE TypeOperators #-} {-| Static sites are the opposite of headless applications, they are disembodied. Shpadoinkle Disembodied is a static site generator for Shpadoinkle applications. -} module Shpadoinkle.Disembodied ( -- * Site Reification Site(..) -- * Class , Disembodied(..) -- * Write Files , writeSite ) where import Control.Monad (void) import Data.Kind (Type) import Data.Proxy (Proxy (..)) import Data.Text (isSuffixOf, pack, unpack) import Data.Text.IO as T (writeFile) import Servant.API import System.Directory (createDirectoryIfMissing) import System.FilePath ((<.>), ()) import UnliftIO.Async (concurrently, forConcurrently_) import GHC.TypeLits (KnownSymbol, symbolVal) import Shpadoinkle (Html) import Shpadoinkle.Backend.Static (renderStatic) import Shpadoinkle.Router (HTML, View) -- | The reification of a static site based on Servant routes. -- Site takes a context `ctx` which is universal for the static site. -- This is useful for storing commonly used valus like the site name, -- site url, copyright date, ect. data Site where -- | A path segment in the URI SPath :: String -- ^ The current URI path segment -> Site -- ^ The site to be rendered at the path -> Site -- | Html to be rendered as @index.html@ SIndex :: forall m a . Html m a -- ^ Given a context, how can we render a page? -> Site -- | Capture is the one Servant combinator that can be meaningful in static -- site generation, and only if we can generate all possible instances. SCapture :: (FromHttpApiData x, ToHttpApiData x, Bounded x, Enum x) => (x -> Site) -- ^ Given a context, provide the remaining site to be generated -> Site -- | Branch the site at a given point in generation. SChoice :: Site -> Site -> Site -- | Type class induction for building the site out of a specification class Disembodied a where -- | A type family to represent the relationship between a Servant API -- and the 'Html' views to render. -- -- @ -- type SPA m = "about" :> Html m () -- :\<|\> Html m () -- -- site :: SiteSpec () (SPA m) -- site = const (h1_ [ text "about" ]) -- :\<|\> const (h1_ [ text "home" ]) -- @ type SiteSpec a :: Type -- | Construct the site structure out of the associated API buildSite :: SiteSpec a -> Site instance (Disembodied x, Disembodied y) => Disembodied (x :<|> y) where type SiteSpec (x :<|> y) = SiteSpec x :<|> SiteSpec y buildSite :: SiteSpec x :<|> SiteSpec y -> Site buildSite (x :<|> y) = SChoice (buildSite @x x) (buildSite @y y) {-# INLINABLE buildSite #-} instance (Disembodied sub, KnownSymbol path) => Disembodied (path :> sub) where type SiteSpec (path :> sub) = SiteSpec sub buildSite :: SiteSpec sub -> Site buildSite = SPath (symbolVal (Proxy @path)) . buildSite @sub {-# INLINABLE buildSite #-} instance (Disembodied sub, FromHttpApiData x, ToHttpApiData x, Bounded x, Enum x) => Disembodied (Capture sym x :> sub) where type SiteSpec (Capture sym x :> sub) = x -> SiteSpec sub buildSite :: (x -> SiteSpec sub) -> Site buildSite = SCapture . (buildSite @sub .) {-# INLINABLE buildSite #-} instance Disembodied sub => Disembodied (QueryParam sym x :> sub) where type SiteSpec (QueryParam sym x :> sub) = Maybe x -> SiteSpec sub buildSite :: (Maybe x -> SiteSpec sub) -> Site buildSite f = buildSite @sub $ f Nothing {-# INLINABLE buildSite #-} instance Disembodied sub => Disembodied (QueryParam' ms sym x :> sub) where type SiteSpec (QueryParam' ms sym x :> sub) = Maybe x -> SiteSpec sub buildSite :: (Maybe x -> SiteSpec sub) -> Site buildSite f = buildSite @sub $ f Nothing {-# INLINABLE buildSite #-} instance Disembodied sub => Disembodied (QueryParams sym x :> sub) where type SiteSpec (QueryParams sym x :> sub) = [x] -> SiteSpec sub buildSite :: ([x] -> SiteSpec sub) -> Site buildSite f = buildSite @sub $ f [] {-# INLINABLE buildSite #-} instance Disembodied sub => Disembodied (QueryFlag sym :> sub) where type SiteSpec (QueryFlag sym :> sub) = Bool -> SiteSpec sub buildSite :: (Bool -> SiteSpec sub) -> Site buildSite f = buildSite @sub $ f False {-# INLINABLE buildSite #-} instance Disembodied (f '[HTML] (Html m a)) where type SiteSpec (f '[HTML] (Html m a)) = Html m a buildSite :: Html m a -> Site buildSite = SIndex {-# INLINABLE buildSite #-} instance Disembodied (View m a) where type SiteSpec (View m a) = Html m a buildSite :: Html m a -> Site buildSite = SIndex {-# INLINABLE buildSite #-} -- | Actually write the site to disk. Branches are written in parallel. writeSite :: forall layout. Disembodied layout => FilePath -- ^ Out path -> SiteSpec layout -- ^ Specification for the pages of the site relative to a Servant API. -> IO () writeSite fs layout = go fs $ buildSite @layout layout where go :: FilePath -> Site -> IO () go curr (SIndex page) = T.writeFile (curr "index" <.> "html") $ renderStatic page go curr (SChoice x y) = void $ go curr x `concurrently` go curr y go curr (SCapture f) = forConcurrently_ [ minBound .. maxBound ] $ \c -> go curr $ SPath (unpack $ toUrlPiece c) $ f c go curr (SPath path (SIndex page)) | ".html" `isSuffixOf` pack path = do createDirectoryIfMissing False curr T.writeFile (curr path) $ renderStatic page go curr (SPath path site) = do createDirectoryIfMissing False (curr path) go (curr path) site