ema-extra-0.10.0.0: src/Ema/Route/Lib/Extra/PandocRoute.hs
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE UndecidableInstances #-}
module Ema.Route.Lib.Extra.PandocRoute (
-- * Route
PandocRoute (..),
mkPandocRoute,
-- * Model and Arg
Model (..),
Arg (..),
-- * Looking up Pandoc values in model
lookupPandocRoute,
-- * Rendering
PandocHtml (..),
PandocError (..),
) where
import Control.Exception (throw, throwIO)
import Control.Monad.Logger (
MonadLogger,
MonadLoggerIO,
logInfoNS,
)
import Data.Default (Default (..))
import Data.List qualified as List
import Data.Map.Strict qualified as Map
import Data.SOP (I (I), NP (..))
import Data.Set qualified as Set
import Ema
import Ema.CLI qualified
import Ema.Route.Generic.TH
import Ema.Route.Lib.Extra.SlugRoute
import Generics.SOP qualified as SOP
import System.FilePath ((</>))
import System.UnionMount qualified as UnionMount
import Text.Pandoc (Pandoc, ReaderOptions, runIO)
import Text.Pandoc qualified as Pandoc
import Text.Pandoc.Sources (ToSources)
import UnliftIO (MonadUnliftIO)
-- | Represents the relative path to a file that pandoc can parse.
newtype PandocRoute = PandocRoute {unPandocRoute :: SlugRoute Pandoc}
deriving stock (Eq, Ord, Show, Generic)
deriving anyclass (SOP.Generic, SOP.HasDatatypeInfo)
deriving
(HasSubRoutes, IsRoute)
via ( GenericRoute
PandocRoute
'[ WithModel Model
, WithSubRoutes
'[ SlugRoute Pandoc
]
]
)
instance HasSubModels PandocRoute where
subModels m = I (Map.mapKeys unPandocRoute $ modelPandocs m) :* Nil
instance IsString PandocRoute where
fromString fp = maybe (error $ "Bad path: " <> toText fp) snd $ mkPandocRoute fp
mkPandocRoute :: FilePath -> Maybe (String, PandocRoute)
mkPandocRoute fp = do
(ext, r) <- mkSlugRoute fp
pure (ext, PandocRoute r)
data Model = Model
{ modelArg :: Arg
, modelPandocs :: Map PandocRoute Pandoc
}
deriving stock (Generic)
lookupPandocRoute :: Model -> PandocRoute -> Maybe (Pandoc, Pandoc -> PandocHtml)
lookupPandocRoute model r = do
pandoc <- Map.lookup r $ modelPandocs model
let render = PandocHtml . renderHtml (argWriterOpts $ modelArg model)
pure (pandoc, render)
where
renderHtml :: HasCallStack => Pandoc.WriterOptions -> Pandoc -> Text
renderHtml writerSettings pandoc =
either (throw . PandocError_RenderError . show) id $
Pandoc.runPure $
Pandoc.writeHtml5String writerSettings pandoc
data Arg = Arg
{ -- Base directory
argBaseDir :: FilePath
, -- Pandoc reader formats supported, as file extension; eg: '.md'
argFormats :: Set String
, argReaderOpts :: Pandoc.ReaderOptions
, argWriterOpts :: Pandoc.WriterOptions
}
deriving stock (Generic)
instance Default Arg where
def = Arg "." formats defaultReaderOpts defaultWriterOpts
where
formats = Set.fromList knownPandocFormats
defaultReaderOpts = def {Pandoc.readerExtensions = exts}
defaultWriterOpts = def {Pandoc.writerExtensions = exts}
exts :: Pandoc.Extensions
exts =
-- Sensible defaults for Markdown and others
Pandoc.pandocExtensions <> Pandoc.extensionsFromList [Pandoc.Ext_attributes]
instance EmaSite PandocRoute where
type SiteArg PandocRoute = Arg
-- Returns the `Pandoc` AST along with the function that renders it to HTML.
type SiteOutput PandocRoute = (Pandoc, Pandoc -> PandocHtml)
siteInput _ arg = do
fmap (Model arg) <$> pandocFilesDyn (argBaseDir arg) (argFormats arg) (argReaderOpts arg)
siteOutput _rp model r = do
maybe (liftIO $ throwIO $ PandocError_Missing $ show r) pure $ lookupPandocRoute model r
pandocFilesDyn ::
forall m.
(MonadIO m, MonadUnliftIO m, MonadLogger m, MonadLoggerIO m) =>
FilePath ->
Set String ->
ReaderOptions ->
m (Dynamic m (Map PandocRoute Pandoc))
pandocFilesDyn baseDir formats readerOpts = do
let pats =
toList formats <&> \ext ->
((), "**/*" <> ext)
ignorePats = [".*"]
model0 = mempty
Dynamic <$> UnionMount.mount baseDir pats ignorePats model0 (const handleUpdate)
where
-- Take the file that got changed and update our in-memory `Model` accordingly.
handleUpdate ::
(MonadIO m, MonadLogger m, MonadLoggerIO m) =>
FilePath ->
UnionMount.FileAction () ->
m (Map PandocRoute Pandoc -> Map PandocRoute Pandoc)
handleUpdate fp = \case
UnionMount.Refresh _ _ -> do
mData <- readSource fp
pure $ maybe id (uncurry Map.insert) mData
UnionMount.Delete ->
pure $ maybe id (Map.delete . snd) $ mkPandocRoute fp
readSource :: (MonadIO m, MonadLogger m, MonadLoggerIO m) => FilePath -> m (Maybe (PandocRoute, Pandoc))
readSource fp = runMaybeT $ do
(ext, r :: PandocRoute) <- hoistMaybe (mkPandocRoute fp)
guard $ ext `Set.member` formats
log $ "Reading " <> toText fp
s :: Text <- fmap decodeUtf8 $ readFileBS $ baseDir </> fp
liftIO (runIO $ readPandocSource readerOpts ext s) >>= \case
Left err -> Ema.CLI.crash "PandocRoute" $ show err
Right doc -> do
pure (r, doc)
log :: MonadLogger m => Text -> m ()
log = logInfoNS "PandocRoute"
data PandocError
= PandocError_Missing Text
| PandocError_RenderError Text
deriving stock (Eq, Show)
deriving anyclass (Exception)
newtype PandocHtml = PandocHtml {unPandocHtml :: Text}
deriving stock (Eq, Generic)
-- Pandoc reader abstraction
--
-- TODO: Can we refactor this using,
-- https://github.com/jgm/pandoc/blob/16f0316fbaa4d667ba40772969ab8e28fea6a493/src/Text/Pandoc/App/FormatHeuristics.hs#L36
knownPandocFormats :: [String]
knownPandocFormats = [".md", ".org"]
formatFromExt :: String -> Text
formatFromExt = \case
".md" -> "markdown"
".org" -> "org"
ext -> throw $ UnsupportedPandocFormat ext
readPandocSource ::
forall m a.
(Pandoc.PandocMonad m, ToSources a) =>
ReaderOptions ->
[Char] ->
a ->
m Pandoc
readPandocSource readerOpts ext s =
case List.lookup (formatFromExt ext) Pandoc.readers of
Just (Pandoc.TextReader f) -> f readerOpts s
_ -> throw $ UnsupportedPandocFormat ext
data UnsupportedPandocFormat = UnsupportedPandocFormat String
deriving stock (Eq, Show)
deriving anyclass (Exception)