packages feed

ema-0.8.0.0: src/Ema/Route/Lib/Extra/StaticRoute.hs

{-# LANGUAGE DeriveAnyClass #-}
{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}

{-# HLINT ignore "Use impureThrow" #-}

module Ema.Route.Lib.Extra.StaticRoute (
  StaticRoute,
  Model (..),

  -- * Helpers
  staticRouteUrl,
) where

import Control.Exception (throw)
import Control.Monad.Logger (MonadLogger, MonadLoggerIO)
import Data.List qualified as List
import Data.Map.Strict qualified as Map
import Data.Some (Some)
import Data.Time (UTCTime, defaultTimeLocale, formatTime, getCurrentTime)
import Ema
import Ema.CLI qualified
import GHC.TypeLits (KnownSymbol, Symbol, symbolVal)
import Optics.Core (Prism', prism')
import System.FilePath (takeExtension, (</>))
import System.UnionMount qualified as UnionMount
import UnliftIO (MonadUnliftIO)

-- | Route to a static file under @baseDir@.
newtype StaticRoute (baseDir :: Symbol) = StaticRoute {unStaticRoute :: FilePath}
  deriving newtype (Eq, Ord, Show)
  deriving stock (Generic)

data Model = Model
  { modelCliAction :: Some Ema.CLI.Action
  , modelFiles :: Map FilePath UTCTime
  }
  deriving stock (Eq, Show, Generic)

instance IsRoute (StaticRoute baseDir) where
  type RouteModel (StaticRoute baseDir) = Model
  routePrism (modelFiles -> files) =
    let enc =
          unStaticRoute
        dec fp =
          StaticRoute fp <$ guard (Map.member fp files)
     in toPrism_ $ prism' enc dec
  routeUniverse (modelFiles -> files) =
    StaticRoute <$> Map.keys files

instance KnownSymbol baseDir => EmaSite (StaticRoute baseDir) where
  siteInput cliAct _ = do
    files <- staticFilesDynamic $ symbolVal (Proxy @baseDir)
    pure $ Model cliAct <$> files
  siteOutput _ _ (StaticRoute path) =
    pure $ Ema.AssetStatic $ symbolVal (Proxy @baseDir) </> path

staticFilesDynamic ::
  forall m.
  (MonadIO m, MonadUnliftIO m, MonadLogger m, MonadLoggerIO m) =>
  FilePath ->
  m (Dynamic m (Map FilePath UTCTime))
staticFilesDynamic baseDir = do
  let pats = [((), "**")]
      ignorePats = [".*"]
      model0 = mempty
  Dynamic <$> UnionMount.mount baseDir pats ignorePats model0 (const handleUpdate)
  where
    handleUpdate ::
      FilePath ->
      UnionMount.FileAction () ->
      m (Map FilePath UTCTime -> Map FilePath UTCTime)
    handleUpdate fp = \case
      UnionMount.Refresh _ _ -> do
        lastAccessed <- liftIO getCurrentTime
        pure $ Map.insert fp lastAccessed
      UnionMount.Delete -> do
        pure $ Map.delete fp

-- | Like `Ema.routeUrl`, but looks up the value and appends it to URL in live-server (for force-reload in browser)
staticRouteUrl ::
  forall s baseDir.
  (IsString s, HasCallStack) =>
  Prism' FilePath (StaticRoute baseDir) ->
  RouteModel (StaticRoute baseDir) ->
  FilePath ->
  s
staticRouteUrl rp model fp =
  let lastAccessed = lookupMust fp model
      tag = toText $ formatTime defaultTimeLocale "%s" lastAccessed
      url = Ema.routeUrl rp $ StaticRoute fp
   in fromString . toString $ url <> refreshAddendum tag
  where
    -- Force the browser to reload the static file referenced
    refreshAddendum tag =
      fromMaybe "" $ do
        -- In live server, force reload all (re-added/modified) static files.
        -- In statically generated site, do it only for CSS and JS files.
        guard $
          Ema.CLI.isLiveServer (modelCliAction model)
            || takeExtension fp `List.elem` [".css", ".js"]
        pure $ "?" <> tag

lookupMust :: FilePath -> Model -> UTCTime
lookupMust fp model =
  case Map.lookup fp (modelFiles model) of
    Just lastAccessed -> lastAccessed
    Nothing -> throw $ MissingStaticFile fp

newtype MissingStaticFile = MissingStaticFile FilePath
  deriving stock (Eq, Show)
  deriving anyclass (Exception)