kitchen-sink-0.1.0.0: src/KitchenSink/Engine/SiteLoader.hs
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE OverloadedRecordDot #-}
module KitchenSink.Engine.SiteLoader (module KitchenSink.Core.Build.Site, loadSite, LogMsg (..)) where
import Control.Exception (throwIO)
import Data.Aeson (FromJSON (..), withObject, (.:))
import Data.Aeson qualified as Aeson
import Data.Aeson.Types qualified as Aeson.Types
import Data.ByteString.Lazy qualified as LByteString
import Data.List qualified as List
import Data.Map (Map)
import Data.Map qualified as Map
import Data.Maybe (isJust)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text
import Data.Text.IO qualified as Text
import System.Directory (listDirectory)
import System.FilePath.Posix (dropExtension, takeExtension, takeFileName, (</>))
import Text.Megaparsec (runParser)
import Text.Mustache qualified as Mustache
import Text.Parsec qualified as Parsec
import Prelude (Integer, ioError, succ, userError, (&&), (||))
import Control.Monad (foldM)
import qualified Tramaj.Ast
import qualified Tramaj.Eval
import Control.Monad.State
import KitchenSink.Core.Build.Site
import KitchenSink.Core.Build.Target
import KitchenSink.Core.Section
import KitchenSink.Engine.Templating (TemplatingError)
import KitchenSink.Engine.Templating qualified as Templating
import KitchenSink.Prelude
data LogMsg ext
= LoadArticle FilePath
| LoadTemplatingLibraryFile FilePath
| LoadImage FilePath
| LoadVideo FilePath
| LoadRaw FilePath
| LoadDocument FilePath
| LoadCss FilePath
| LoadWebfont FilePath
| LoadJs FilePath
| LoadHtml FilePath
| LoadDotSource FilePath
| EvalSection FilePath (SectionType ext) Format
deriving (Show)
type Loader ext a = (LogMsg ext -> IO ()) -> FilePath -> IO (Sourced a)
-- TODO: consider adding some LoadedArticle type to:
-- - distinguish article that have just been parsed from pre-processed ones
-- - return some extra structure about the Article like:
-- * dependencies using a specific section
-- * dependencies between sections? or between articles?
-- * dependencies to external query widgets or params?
-- * references to generated datasets (e.g., `curl a page, use as input to other place`)
loadArticle :: [(Text, Text)] -> Text -> [ExtraSectionType ext] -> Tramaj.Eval.LibraryTable -> Loader ext (Article ext [Text])
loadArticle vars pathPrefix extras globalLibs trace path = do
trace $ LoadArticle path
eart <- runParser (article extras path) path <$> Text.readFile path
case eart of
Left err -> throwIO err
Right art -> Sourced (FileSource path) <$> evalSections art
where
env = EvalEnv path vars pathPrefix trace
evalSections art = evalStateT (overSections (evalSection env) art) (newState (isDynamicArticle art) globalLibs)
{- | Whether an article declares a @route@ in its @=base:build-info.json@
section, which makes it a request-time dynamic page (see
"KitchenSink.Engine.Dynamic"). Sections of such an article that would
otherwise be evaluated against @$ctx@ at load time (its @.tramaj-doc@ /
@.tramaj-json@ sections, most importantly its @main-content@) are instead
left untouched here, since @$ctx.request@ only exists once an actual request
comes in.
-}
isDynamicArticle :: Article ext [Text] -> Bool
isDynamicArticle (Article _ secs) =
List.any hasRoute secs
where
hasRoute (Section BuildInfo Json body) =
case Aeson.decode (LByteString.fromStrict $ Text.encodeUtf8 $ Text.unlines body) of
Just (binfo :: BuildInfoData) -> isJust (route binfo)
Nothing -> False
hasRoute _ = False
{- | Parses every @library.templating-lib@ section out of a set of
templating-library-only files (a @*.cmark-tramaj@ file, picked up by
'loadSite' and excluded from the site's articles) into one shared
'Tramaj.Eval.LibraryTable', which every article is then seeded with (see
'loadArticle').
Every name a file registers is nested under that file's own basename, so
@bob.cmark-tramaj@'s @icon@ library is only ever reachable as
@import(\"bob\/icon\", ...)@. Two files can therefore never collide on a name
-- the filesystem itself already guarantees two files in one directory don't
share a basename -- so 'DuplicateTemplatingLibrary' below can only ever fire
within one file, same as the ordinary in-article case.
Deliberately just parsing, not evaluating: a library's body is not run here
any more than an in-article @library.templating-lib@ section's body is run
where it is declared (see 'sectionStep') -- it is run later, wherever
something actually imports it and reads @.rendered@ off the result, against
whatever library table /that/ call site has. Which library file 'loadSite'
processes first therefore cannot matter for correctness; a genuine import
cycle (two libraries, from the same file or different ones, that import each
other) is caught by @tramaj@ itself as an 'Tramaj.Eval.EvalError'
('Tramaj.Eval.ImportCycle') when something finally evaluates that chain, not
by anything here.
-}
loadTemplatingLibraries ::
(LogMsg ext -> IO ()) ->
[ExtraSectionType ext] ->
[FilePath] ->
IO Tramaj.Eval.LibraryTable
loadTemplatingLibraries trace extras = foldM loadFile Map.empty
where
loadFile acc path = do
trace $ LoadTemplatingLibraryFile path
eart <- runParser (article extras path) path <$> Text.readFile path
case eart of
Left err -> throwIO err
Right (Article _ secs) -> do
fileTable <- foldM (registerSection path) Map.empty secs
let prefix = Text.pack (dropExtension (takeFileName path))
pure $ Map.union (Map.mapKeys ((prefix <> "/") <>) fileTable) acc
registerSection path acc (Section (Library name) TramajLib body) = do
case Templating.parseLibrarySection (Text.unlines body) of
Left err -> throwIO $ TemplatingSectionError path err
Right prog
| Map.member name acc -> throwIO $ DuplicateTemplatingLibrary name path
| otherwise -> pure $ Map.insert name prog acc
registerSection _ acc _ = pure acc
{- | What an evaluated section (templating-lang in expression mode)
must answer with: a @format@ naming the concrete format the section is rewritten
to, plus its @contents@.
-}
data SectionEvalResult
= TextContents Text [Text]
| JsonContents Aeson.Value
deriving (Show)
instance FromJSON SectionEvalResult where
parseJSON = withObject "SectionEvalResult" $ \obj -> do
format <- obj .: "format"
case format of
"json" -> JsonContents <$> obj .: "contents"
_ -> TextContents format <$> (obj .: "contents" >>= textContents)
where
-- a single string is accepted as well as an array of lines, since
-- writing `[ "…" ]` for a one-line result is pure noise
textContents :: Aeson.Value -> Aeson.Types.Parser [Text]
textContents (Aeson.String txt) = pure [txt]
textContents v = parseJSON v
data EvalEnv ext
= EvalEnv
{ path :: FilePath
, vars :: [(Text, Text)]
, pathPrefix :: Text
, trace :: LogMsg ext -> IO ()
}
data EvalError
= UnsupportedReturnFormat Text
| MalformedJSONDataset Name String
| -- | the file holding the section, and what the JSON decoder said
MalformedJSONGeneratorInstructions FilePath String
| MustacheCompileError Parsec.ParseError
| TemplatingSectionError FilePath TemplatingError
| TemplatingResultJsonDecodeError FilePath String
| -- | the same library name was registered twice within one
-- @*.cmark-tramaj@ file; carries the name and the file
DuplicateTemplatingLibrary Name FilePath
deriving (Show, Exception)
type DatasetCells =
Map Name Aeson.Value
data EvalState = EvalState
{ sectionNumber :: Integer
, datasets :: DatasetCells
, templatingLibraryTable :: Tramaj.Eval.LibraryTable
, dynamicArticle :: Bool
-- ^ set once, from the article's own @route@ build-info; see 'isDynamicArticle'
}
newState :: Bool -> Tramaj.Eval.LibraryTable -> EvalState
newState dyn globalLibs = EvalState 0 Map.empty globalLibs dyn
type Eval a = StateT EvalState IO a
evalSection :: EvalEnv ext -> Section ext [Text] -> Eval (Section ext [Text])
evalSection env s = do
x <- sectionStep env s
incrementSectionNumber
pure x
incrementSectionNumber :: Eval ()
incrementSectionNumber = modify f
where
f st0 = st0{sectionNumber = succ (sectionNumber st0)}
recordTemplatingLibrary :: Text -> Tramaj.Ast.Program -> Eval ()
recordTemplatingLibrary key x = modify f
where
f st0 = st0{templatingLibraryTable = g (templatingLibraryTable st0) }
g libtable = Map.insert key x libtable
insertDatasetContents :: Name -> Aeson.Value -> Eval ()
insertDatasetContents k val = modify f
where
f st0 = st0{datasets = Map.insert k val (datasets st0)}
sectionStep :: forall ext. EvalEnv ext -> Section ext [Text] -> Eval (Section ext [Text])
sectionStep env x@(Section t fmt body) = do
st0 <- get
liftIO $ env.trace $ EvalSection env.path t fmt
exec st0
where
exec :: EvalState -> Eval (Section ext [Text])
-- a dynamic page's tramaj sections (most importantly its main-content)
-- read $ctx.request, which only exists per-request; leave them as source
-- for "KitchenSink.Engine.Dynamic" to evaluate later, not here
exec st0
| st0.dynamicArticle && (fmt == TramajDoc || fmt == TramajJson) =
pure x
exec st0 = case (t, fmt) of
-- a .sql dataset is never evaluated at load time either: it runs
-- per-request against a read-only sqlite datasource, see
-- "KitchenSink.Engine.Dynamic"
(Dataset _, Sql) -> pure x
(_, Mustache) -> do
let jsonDataset = Aeson.toJSON st0.datasets
let template = Mustache.compileTemplate "(section)" (Text.unlines body)
case template of
Left err -> liftIO $ throwIO $ MustacheCompileError err
Right tpl -> do
let contents = Mustache.substitute tpl jsonDataset
pure $ Section t Cmark [contents]
(_, Dhall) ->
liftIO
$ ioError
$ userError
$ env.path
<> ": the dhall section format was removed; rewrite this section as a tramaj section (tramaj-json for a value, tramaj-doc for HTML), see /sections-templating.html"
(_, TramajJson) -> do
let ctx = Templating.buildContext env.path st0.sectionNumber env.pathPrefix env.vars st0.datasets
case Templating.evalJsonSection st0.templatingLibraryTable ctx (Text.unlines body) of
Left err -> liftIO $ throwIO $ TemplatingSectionError env.path err
Right (prog,jvalue) -> case Aeson.fromJSON jvalue of
Aeson.Error err ->
liftIO $ throwIO $ TemplatingResultJsonDecodeError env.path err
Aeson.Success result -> do
recordTemplatingLibrary (Text.pack $ show $ st0.sectionNumber) prog
rewriteSection "tramaj-json" result
(_, TramajDoc) -> do
let ctx = Templating.buildContext env.path st0.sectionNumber env.pathPrefix env.vars st0.datasets
case Templating.evalDocSection st0.templatingLibraryTable ctx (Text.unlines body) of
Left err -> liftIO $ throwIO $ TemplatingSectionError env.path err
Right (prog, html) -> do
recordTemplatingLibrary (Text.pack $ show $ st0.sectionNumber) prog
pure $ Section t TextHtml [html]
(Library name, TramajLib) -> do
case Templating.parseLibrarySection (Text.unlines body) of
Left err -> liftIO $ throwIO $ TemplatingSectionError env.path err
Right prog -> do
recordTemplatingLibrary (Text.pack $ show $ st0.sectionNumber) prog
recordTemplatingLibrary name prog
pure $ Section t TextHtml []
(Dataset name, Json) -> do
case (Aeson.eitherDecode $ LByteString.fromStrict $ Text.encodeUtf8 $ Text.unlines body) of
Right v -> insertDatasetContents name v
Left err -> liftIO $ throwIO $ MalformedJSONDataset name err
pure x
(Dataset name, _) -> do
insertDatasetContents name (Aeson.String $ Text.unlines body)
pure x
(GeneratorInstructions, Json) -> do
let jsonDataset = Aeson.toJSON st0.datasets
case (Aeson.eitherDecode @GeneratorInstructionsData $ LByteString.fromStrict $ Text.encodeUtf8 $ Text.unlines body) of
Left err -> liftIO $ throwIO $ MalformedJSONGeneratorInstructions env.path err
Right gen ->
if isJust gen.stdin_json || isJust gen.stdin
then pure x
else
pure
$ Section
GeneratorInstructions
Json
[Text.decodeUtf8 $ LByteString.toStrict $ Aeson.encode $ gen{stdin_json = Just jsonDataset}]
_ ->
pure x
{- | Rewrites an evaluated section into the concrete format its result asked
for; @backend@ names the section format for the error message.
A generated dataset cell is registered here too: the format-dispatching
branches above match before @(Dataset name, Json)@ does, so without this a
@=base:dataset.tramaj-json my-name@ cell would be rewritten to
JSON and then stay invisible to every later section.
-}
rewriteSection :: Text -> SectionEvalResult -> Eval (Section ext [Text])
rewriteSection _ (JsonContents obj) = do
case t of
Dataset name -> insertDatasetContents name obj
_ -> pure ()
pure $ Section t Json [Text.decodeUtf8 $ LByteString.toStrict $ Aeson.encode obj]
rewriteSection backend (TextContents newFormat contents) =
case newFormat of
"cmark" -> pure $ Section t Cmark contents
"html" -> pure $ Section t TextHtml contents
"css" -> pure $ Section t Css contents
unsupportedFmt ->
liftIO
$ throwIO
$ UnsupportedReturnFormat
$ "unknown returned " <> backend <> " format: " <> unsupportedFmt
loadImage :: Loader a Image
loadImage trace path = do
trace $ LoadImage path
pure $ (Sourced (FileSource path) Image)
loadAudio :: Loader a AudioFile
loadAudio trace path = do
trace $ LoadVideo path
pure $ (Sourced (FileSource path) AudioFile)
loadVideo :: Loader a VideoFile
loadVideo trace path = do
trace $ LoadVideo path
pure $ (Sourced (FileSource path) VideoFile)
loadRaw :: Loader a RawFile
loadRaw trace path = do
trace $ LoadRaw path
pure $ (Sourced (FileSource path) RawFile)
loadDocument :: Loader a DocumentFile
loadDocument trace path = do
trace $ LoadDocument path
pure $ (Sourced (FileSource path) DocumentFile)
loadCss :: Loader a CssFile
loadCss trace path = do
trace $ LoadCss path
pure $ (Sourced (FileSource path) CssFile)
loadFont :: Loader a WebfontFile
loadFont trace path = do
trace $ LoadWebfont path
pure $ (Sourced (FileSource path) WebfontFile)
loadJs :: Loader a JsFile
loadJs trace path = do
trace $ LoadJs path
pure $ (Sourced (FileSource path) JsFile)
loadHtml :: Loader a HtmlFile
loadHtml trace path = do
trace $ LoadHtml path
pure $ (Sourced (FileSource path) HtmlFile)
loadDotSource :: Loader a DotSourceFile
loadDotSource trace path = do
trace $ LoadDotSource path
pure $ (Sourced (FileSource path) DotSourceFile)
loadSite ::
[(Text, Text)] ->
Text ->
[ExtraSectionType ext] ->
(LogMsg ext -> IO ()) ->
FilePath ->
IO (Site ext)
loadSite vars pathPrefix extras trace dir = do
paths <- listDirectory dir
globalLibs <- loadTemplatingLibraries trace extras (libraryPaths paths)
Site
<$> articlesM globalLibs paths
<*> imagesM paths
<*> videosM paths
<*> audiosM paths
<*> cssM paths
<*> fontsM paths
<*> jsM paths
<*> htmlM paths
<*> dotsM paths
<*> rawsM paths
<*> docsM paths
where
-- Library-only files: a dedicated extension (not merely a naming
-- convention on top of .cmark/.md) so an ordinary article can never be
-- mistaken for one, or vice versa. They generate no target of their own
-- -- 'articlesM' below never sees them, since their extension doesn't
-- match its filter -- and are instead parsed for their
-- @library.templating-lib@ sections into one shared table every article
-- can import from. Sorted only so 'LoadTemplatingLibraryFile' tracing is
-- in a stable, deterministic order; which file is processed first does
-- not otherwise matter (see 'loadTemplatingLibraries').
libraryPaths paths = List.sort [dir </> p | p <- paths, takeExtension p == ".cmark-tramaj"]
articlesM globalLibs paths =
traverse (loadArticle vars pathPrefix extras globalLibs trace)
$ [dir </> p | p <- paths, takeExtension p `List.elem` [".md", ".cmark"]]
imagesM paths =
traverse (loadImage trace)
$ [dir </> p | p <- paths, takeExtension p `List.elem` [".jpg", ".jpeg", ".png"]]
cssM paths =
traverse (loadCss trace)
$ [dir </> p | p <- paths, takeExtension p `List.elem` [".css"]]
fontsM paths =
traverse (loadFont trace)
$ [dir </> p | p <- paths, takeExtension p `List.elem` [".ttf", ".woff2"]]
jsM paths =
traverse (loadJs trace)
$ [dir </> p | p <- paths, takeExtension p `List.elem` [".js"]]
htmlM paths =
traverse (loadHtml trace)
$ [dir </> p | p <- paths, takeExtension p `List.elem` [".html"]]
dotsM paths =
traverse (loadDotSource trace)
$ [dir </> p | p <- paths, takeExtension p == ".dot"]
videosM paths =
traverse (loadVideo trace)
$ [dir </> p | p <- paths, takeExtension p `List.elem` [".webm", ".mp4"]]
audiosM paths =
traverse (loadAudio trace)
$ [dir </> p | p <- paths, takeExtension p `List.elem` [".ogg", ".mp3", ".wav", ".midi", ".flac"]]
rawsM paths =
traverse (loadRaw trace)
$ [dir </> p | p <- paths, takeExtension p `List.elem` [".txt", ".csv", ".json", ".dhall"], takeFileName p /= "kitchen-sink.json"]
docsM paths =
traverse (loadDocument trace)
$ [dir </> p | p <- paths, takeExtension p `List.elem` [".pdf"]]