packages feed

ema-0.8.0.0: src/Ema/Generate.hs

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE DeriveAnyClass #-}

module Ema.Generate (
  generateSiteFromModel,
  generateSiteFromModel',
) where

import Control.Exception (throwIO)
import Control.Monad.Except (MonadError (throwError))
import Control.Monad.Logger (
  LogLevel (LevelError, LevelInfo),
  MonadLogger,
  MonadLoggerIO,
  logWithoutLoc,
 )
import Ema.Asset (Asset (..))
import Ema.CLI (crash)
import Ema.Route.Class (IsRoute (RouteModel, routePrism, routeUniverse))
import Ema.Route.Prism (
  checkRoutePrismGivenRoute,
  fromPrism_,
 )
import Ema.Site (EmaSite (siteOutput), EmaStaticSite)
import Optics.Core (review)
import System.Directory (copyFile, createDirectoryIfMissing, doesDirectoryExist, doesFileExist, doesPathExist)
import System.FilePath (takeDirectory, (</>))
import System.FilePattern.Directory (getDirectoryFiles)

log :: MonadLogger m => LogLevel -> Text -> m ()
log = logWithoutLoc "ema.generate"

{- | Generate the static site at `dest`

  The *only* data we need is the `RouteModel`.
-}
generateSiteFromModel ::
  forall r m.
  (MonadIO m, MonadLoggerIO m, MonadFail m, Eq r, Show r, IsRoute r, EmaStaticSite r) =>
  -- | Target directory to write files to. Must exist.
  FilePath ->
  -- | The model data used to generate assets.
  RouteModel r ->
  m [FilePath]
generateSiteFromModel dest model =
  withBlockBuffering $ do
    runExceptT (generateSiteFromModel' @r dest model) >>= \case
      Left err -> do
        crash "ema" err
      Right fs ->
        pure fs
  where
    -- Temporarily use block buffering before calling an IO action that is
    -- known ahead to log rapidly, so as to not hamper serial processing speed.
    withBlockBuffering f =
      hSetBuffering stdout (BlockBuffering Nothing)
        *> f
        <* (hSetBuffering stdout LineBuffering >> hFlush stdout)

-- | Like `generateSiteFromModel` but without buffering or error handling.
generateSiteFromModel' ::
  forall r m.
  (MonadIO m, MonadLoggerIO m, MonadError Text m, Eq r, Show r, EmaStaticSite r) =>
  FilePath ->
  RouteModel r ->
  -- | List of generated files.
  m [FilePath]
generateSiteFromModel' dest model = do
  let enc = routePrism @r
      rp = fromPrism_ $ enc model
  -- Sanity checks
  unlessM (liftIO $ doesDirectoryExist dest) $ do
    throwError $ "Destination directory does not exist: " <> toText dest
  let routes = routeUniverse @r model
  when (null routes) $
    throwError "Your app's `routeUniverse` is empty; nothing to generate!"
  forM_ routes $ \route ->
    checkRoutePrismGivenRoute enc model route
      `whenLeft_` throwError
  -- For Github Pages
  noBirdbrainedJekyll dest
  -- Enumerate and write all routes.
  log LevelInfo $ "Writing " <> show (length routes) <> " routes"
  fmap concat . forM routes $ \r -> do
    let fp = dest </> review rp r
    siteOutput rp model r >>= \case
      AssetStatic staticPath -> do
        liftIO (doesPathExist staticPath) >>= \case
          True ->
            -- NOTE: A static path can indeed be a directory. The user is not
            -- obliged to recursively list the files.
            copyRecursively staticPath fp
          False ->
            log LevelError $ toText $ "? " <> staticPath <> " (missing)"
        pure []
      AssetGenerated _fmt !s -> do
        writeFileGenerated fp s
        pure [fp]

{- | Disable birdbrained hacks from GitHub to disable surprises like,
 https://github.com/jekyll/jekyll/issues/55
-}
noBirdbrainedJekyll :: (MonadIO m, MonadLoggerIO m) => FilePath -> m ()
noBirdbrainedJekyll dest = do
  let nojekyll = dest </> ".nojekyll"
  liftIO (doesFileExist nojekyll) >>= \case
    True -> pass
    False -> do
      log LevelInfo $ "Disabling Jekyll by writing " <> toText nojekyll
      writeFileLBS nojekyll ""

newtype StaticAssetMissing = StaticAssetMissing FilePath
  deriving stock (Show)
  deriving anyclass (Exception)

writeFileGenerated :: (MonadLogger m, MonadIO m) => FilePath -> LByteString -> m ()
writeFileGenerated fp s = do
  log LevelInfo $ toText $ "W " <> fp
  liftIO $ do
    createDirectoryIfMissing True (takeDirectory fp)
    writeFileLBS fp s

{- | Copy a file or directory recursively to the target directory

  Like `cp -R src dest`.
-}
copyRecursively ::
  forall m.
  ( MonadIO m
  , MonadLoggerIO m
  ) =>
  -- | Absolute path to source file or directory to copy.
  FilePath ->
  -- | Target file or directory path.
  FilePath ->
  m ()
copyRecursively src dest = do
  fs <- enumerateFilesToCopy src dest
  forM_ fs $ \(a, b) -> do
    log LevelInfo $ toText $ "C " <> b
    copyFileCreatingParents a b
  where
    enumerateFilesToCopy :: FilePath -> FilePath -> m [(FilePath, FilePath)]
    enumerateFilesToCopy a b = do
      liftIO (doesFileExist a) >>= \case
        True ->
          pure [(a, b)]
        False -> do
          liftIO (doesDirectoryExist a) >>= \case
            False ->
              liftIO $ throwIO $ StaticAssetMissing a
            True -> do
              fs <- liftIO $ getDirectoryFiles src ["**"]
              pure $ fs <&> \fp -> (a </> fp, b </> fp)

    copyFileCreatingParents a b =
      liftIO $ do
        createDirectoryIfMissing True (takeDirectory b)
        copyFile a b