packages feed

hakyll-diagrams-0.1.0.0: src/Hakyll/Web/Pandoc/Diagrams.hs

{-# LANGUAGE OverloadedStrings #-}

{- |
License     : BSD-3-Clause
Maintainer  : Renato Garcia
-}
module Hakyll.Web.Pandoc.Diagrams (
  drawDiagrams,
  drawDiagramsWith,
  Options (..),
  defaultOptions,
  readOptionsFromMetadata,
  readOptionsFromMetadataWith,
) where

import Control.Exception (throw)
import qualified Crypto.Hash.SHA1 as SHA1
import qualified Data.ByteString.Base16 as B16
import Data.Default (Default, def)
import Data.Functor ((<&>))
import Data.List (intercalate)
import Data.List.Split (splitOn)
import Data.Maybe (fromMaybe, maybeToList)
import Data.Text (Text, unpack)
import qualified Data.Text as T (drop, isPrefixOf, length, null, pack, take, unpack, unwords)
import qualified Data.Text.Encoding as TE
import qualified Diagrams.Backend.SVG as SVG
import Diagrams.Core (renderDia)
import Diagrams.Prelude (Any, QDiagram, SizeSpec, V2, mkSizeSpec2D)
import Graphics.Svg.Core (makeAttribute)
import Hakyll (Compiler, destinationDirectory, makeDirectories, splitAll, trim)
import Hakyll.Core.Compiler (getUnderlying, unsafeCompiler)
import Hakyll.Core.Compiler.Internal (CompilerRead (..), compilerAsk)
import Hakyll.Core.Metadata (getMetadataField)
import Language.Haskell.Interpreter (OptionVal ((:=)))
import qualified Language.Haskell.Interpreter as Hint
import qualified Language.Haskell.Interpreter.Unsafe as Hint
import System.Environment (lookupEnv)
import Text.Pandoc.Definition (Block (..), Caption (Caption), Inline (..), Pandoc)
import Text.Pandoc.Walk (walkM)
import Text.Read (readMaybe)


-- | Configure the interpreter environment used when rendering the diagrams.
data Options = Options
  { globalModules :: [(String, Maybe String)]
  -- ^ Global modules to import ("module name", "namespace qualifier")
  , localModules :: [(String, Maybe String)]
  -- ^ Local modules to import ("module name", "namespace qualifier")
  , searchPaths :: [FilePath]
  -- ^ The paths where to search for local modules
  , languageExtensions :: [String]
  -- ^ Language extensions in use by the interpreter
  }
  deriving (Show, Eq)


instance Default Options where
  def = defaultOptions


{- | The default value will import "Prelude", "Diagrams.Prelude", and
"Diagrams.Backend.SVG" global modules. All other fields will be emtpty.
-}
defaultOptions :: Options
defaultOptions =
  Options
    { globalModules =
        [ ("Prelude", Nothing)
        , ("Diagrams.Prelude", Nothing)
        , ("Diagrams.Backend.SVG", Nothing)
        ]
    , localModules = []
    , searchPaths = []
    , languageExtensions = []
    }


splitAtComma :: String -> [String]
splitAtComma = fmap trim . splitAll ","


readModule :: String -> (String, Maybe String)
readModule x =
  case (fmap trim . splitAll " as ") x of
    [a] -> (a, Nothing)
    [a, b] -> (a, Just b)
    a -> throw $ userError ("Invalid syntax of module metadata: " ++ intercalate " as " a)


expandEllipsis
  :: [a]
  -- ^ The original value that will replace any ellipsis occurrence
  -> (String -> a)
  -- ^ The function that will read the element from a String
  -> [String]
  -- ^ The list of comma-separated elements from a metadata field
  -> [a]
  -- ^ The resulting element list with all ellipses replaced
expandEllipsis original transf val = val >>= \x -> if x == "..." then original else [transf x]


{- | Read the 'Options' values from
[metadata block](https://jaspervdj.be/hakyll/tutorials/02-basics.html#pages-and-metadata).

@
---
dg.localModules: Utils, Commons as Cm
dg.searchPaths: lib, ..., posts
dg.languageExtensions: ""
---
@

Each field must be prefixed with a @dg.@ string and must be formatted as a list where each
element is separated by a comma (@,@).

Any occurrence of an ellipsis (@...@) will be replaced by the current value of that field
as in the 'Options' first argument. So to append one element at the end of a list, we can
do: "@..., element@".

When setting either 'globalModules' or 'localModules', a value like "@Utils@" will
translate to @(\"Utils\", Nothing)@, an unqualified import. A value like "@Commons as Cm@"
will translate to @(\"Commons\", Just \"Cm\")@, a qualified import.

Any field not present will retain the same value as in the base 'Options'. An empty string
@""@ will set the field to an empty list.
-}
readOptionsFromMetadataWith
  :: Options
  -- ^ The base 'Options' which will be modified by the metadata values
  -> Compiler Options
  -- ^ The resulting modified 'Options'
readOptionsFromMetadataWith opts = do
  underlyingId <- getUnderlying
  gm <- getMetadataField underlyingId "dg.globalModules"
  lm <- getMetadataField underlyingId "dg.localModules"
  sp <- getMetadataField underlyingId "dg.searchPaths"
  le <- getMetadataField underlyingId "dg.languageExtensions"
  pure $
    opts
      { globalModules = maybe (globalModules opts) (expandEllipsis (globalModules opts) readModule <$> splitAtComma) gm
      , localModules = maybe (localModules opts) (expandEllipsis (localModules opts) readModule <$> splitAtComma) lm
      , searchPaths = maybe (searchPaths opts) (expandEllipsis (searchPaths opts) id <$> splitAtComma) sp
      , languageExtensions = maybe (languageExtensions opts) (expandEllipsis (languageExtensions opts) id <$> splitAtComma) le
      }


-- | Call 'readOptionsFromMetadataWith' with 'defaultOptions' as the argument
readOptionsFromMetadata :: Compiler Options
readOptionsFromMetadata = readOptionsFromMetadataWith defaultOptions


setUpInterpreter :: Options -> Text -> Hint.Interpreter (QDiagram SVG.SVG V2 Double Any)
setUpInterpreter opts code = do
  Hint.set
    [ Hint.searchPath := searchPaths opts
    , Hint.languageExtensions := (parseExtension <$> languageExtensions opts)
    ]
  Hint.loadModules $ fst <$> localModules opts
  Hint.setImportsQ $ globalModules opts ++ localModules opts
  Hint.interpret (T.unpack code) (Hint.as :: QDiagram SVG.SVG V2 Double Any)
  where
    parseExtension :: String -> Hint.Extension
    parseExtension t =
      fromMaybe (throw $ userError ("Invalid language extension: " ++ t)) (readMaybe t)


{- | Parse the values of the HAKYLL_DIAGRAMS_PACKAGE_DB and HAKYLL_DIAGRAMS_PACKAGE_ENV
environment variables and build a list of interpreter arguments.
HAKYLL_DIAGRAMS_PACKAGE_DB can contain N comma-separated values
HAKYLL_DIAGRAMS_PACKAGE_ENV can contain a single value only
-}
getInterpreterArgs :: IO [String]
getInterpreterArgs = do
  packageEnv <- lookupEnv "HAKYLL_DIAGRAMS_PACKAGE_ENV" <&> maybe [] (\x -> ["-package-env", x])
  packageDbs <- lookupEnv "HAKYLL_DIAGRAMS_PACKAGE_DB" <&> maybe [] (prependArgNames . splitOn ":")
  pure $ packageEnv ++ packageDbs
  where
    prependArgNames :: [String] -> [String]
    prependArgNames = concatMap (\x -> ["-package-db", x])


runInterpreter
  :: Hint.Interpreter (QDiagram SVG.SVG V2 Double Any)
  -> IO (Either Hint.InterpreterError (QDiagram SVG.SVG V2 Double Any))
runInterpreter i = do
  mSandbox <- getInterpreterArgs
  case mSandbox of
    [] -> Hint.runInterpreter i
    a -> Hint.unsafeRunInterpreterWithArgs a i


buildDiagram :: Options -> Text -> IO (QDiagram SVG.SVG V2 Double Any)
buildDiagram opts code = do
  result <- runInterpreter $ setUpInterpreter opts code
  case result of
    Right diagram -> pure diagram
    Left (Hint.WontCompile errs) ->
      fail $
        foldl (\str err -> str ++ "\n" ++ Hint.errMsg err) "" errs
    Left err -> fail $ show err


hashCodePrefix :: Text -> Text
hashCodePrefix code =
  let hash = T.take 8 . TE.decodeUtf8 . B16.encode . SHA1.hash . TE.encodeUtf8 $ code
   in "dia_" <> hash <> "_"


genInlineSvg :: Options -> Text -> SizeSpec V2 Double -> Text -> [Text] -> [(Text, Text)] -> IO Text
genInlineSvg opts code imageSize elementId classes attributes =
  T.pack . show . renderDia SVG.SVG svgOptions <$> buildDiagram opts code
  where
    attrs =
      attributes
        ++ [("id", elementId) | not . T.null $ elementId]
        ++ [("class", T.unwords classes) | not . null $ classes]

    svgOptions =
      SVG.SVGOptions
        imageSize
        Nothing
        (hashCodePrefix code)
        [makeAttribute k v | (k, v) <- attrs]
        False


genImageFile
  :: Options
  -- ^ Interpreter configuration
  -> FilePath
  -- ^ Destination directory
  -> FilePath
  -- ^ Output path relative to destination
  -> Text
  -- ^ Haskell code to interpret
  -> SizeSpec V2 Double
  -- ^ Output image size
  -> [(Text, Text)]
  -> IO ()
genImageFile opts destDir relPath code imageSize attributes
  | null relPath = fail "The `relPath` attribute of a diagram can not be an empty string."
  | otherwise = do
      makeDirectories imagePath
      SVG.renderSVG' imagePath svgOptions =<< buildDiagram opts code
  where
    imagePath = destDir ++ "/" ++ relPath
    svgOptions =
      SVG.SVGOptions
        imageSize
        Nothing
        (hashCodePrefix code)
        [makeAttribute k v | (k, v) <- attributes]
        True


imageBlock :: Text -> [Text] -> [(Text, Text)] -> [Inline] -> Text -> Text -> Inline
imageBlock elementId classes attributes alt path title = Image (elementId, classes, attributes) alt (path, title)


figureBlock :: Text -> [Text] -> [(Text, Text)] -> Text -> Block -> Block
figureBlock elementId classes attributes caption img =
  -- ShortCaption is ignored by Pandoc when generating HTML
  Figure (elementId, classes, attributes) (Caption Nothing [Plain [Str caption]]) [img]


-- | Compiles the Diagrams code and transforms a code block with a .diagram class in a figure block
transformBlock :: Options -> FilePath -> Block -> IO Block
transformBlock opts destDir (CodeBlock (elementId, classes, keyVals) code)
  | Just relpath <- lookup "img:src" keyVals
  , Just caption <- lookup "figcaption" keyVals = do
      genImageFile opts destDir (unpack relpath) code (mkSizeSpec2D svgWidth svgHeight) (tagAttributes "svg")
      pure $
        figureBlock elementId classes (tagAttributes "figure") caption $
          Plain [imageBlock "" [] (tagAttributes "img") altText relpath imgTitle]
  | Just relpath <- lookup "img:src" keyVals = do
      genImageFile opts destDir (unpack relpath) code (mkSizeSpec2D svgWidth svgHeight) (tagAttributes "svg")
      pure $ Plain [imageBlock elementId classes (tagAttributes "img") altText relpath imgTitle]
  | Just caption <- lookup "figcaption" keyVals = do
      svgText <- genInlineSvg opts code (mkSizeSpec2D svgWidth svgHeight) "" [] (tagAttributes "svg")
      pure $ figureBlock elementId classes (tagAttributes "figure") caption $ RawBlock "html" svgText
  | otherwise = do
      svgText <- genInlineSvg opts code (mkSizeSpec2D svgWidth svgHeight) elementId classes (tagAttributes "svg")
      pure $ RawBlock "html" svgText
  where
    tagAttributes :: Text -> [(Text, Text)]
    tagAttributes tag =
      [ (T.drop (T.length prefix) k, v)
      | (k, v) <- keyVals
      , T.isPrefixOf prefix k
      , k `notElem` specialAttributes
      ]
      where
        prefix = tag <> ":"
        specialAttributes = ["img:src", "img:alt", "img:title", "svg:width", "svg:height"]

    altText = maybeToList (Str <$> lookup "img:alt" keyVals)

    imgTitle = fromMaybe "" (lookup "img:title" keyVals)

    svgWidth =
      fromMaybe (error "Failed to parse `svg:width` attribute value.")
        . readMaybe
        . unpack
        <$> lookup "svg:width" keyVals
    svgHeight =
      fromMaybe (error "Failed to parse `svg:height` attribute value.")
        . readMaybe
        . unpack
        <$> lookup "svg:height" keyVals
transformBlock _ _ block = pure block


{- | Render the code inside all 'Text.Pandoc.Definition.CodeBlock' with a @.diagram@ class
to either a 'Text.Pandoc.Definition.RawBlock' or an 'Text.Pandoc.Definition.Image'
(depending on if we have received an attribute with a path to an external file), where
these two can or cannot be within a parent 'Text.Pandoc.Definition.Figure' (depending on
if we have received an attribute with a figure caption).

The [hint library](https://github.com/haskell-hint/hint?tab=readme-ov-file) is used to
interpret the Haskell code, and therefore all of its
[limitations](https://github.com/haskell-hint/hint?tab=readme-ov-file#limitations) will
apply here as well. In particular, if there isn't a @.ghc.environment.\<something>@
[package environment
file](https://ghc.gitlab.haskell.org/ghc/doc/users_guide/packages.html#package-environments)
(as generated by cabal
[@--write-ghc-environment-files=always@](https://cabal.readthedocs.io/en/3.4/cabal-project.html#cfg-field-write-ghc-environment-files)
option), setting either @HAKYLL_DIAGRAMS_PACKAGE_ENV@ (a single path) or
@HAKYLL_DIAGRAMS_PACKAGE_DB@ (N comma-separated paths) environment variables may be
required. If these environment variables are defined, their values will be used to set the
GHC
[@-package-env@](https://ghc.gitlab.haskell.org/ghc/doc/users_guide/packages.html#ghc-flag-package-env-file-name)
and
[@-package-db@](https://ghc.gitlab.haskell.org/ghc/doc/users_guide/packages.html#ghc-flag-package-db-file)
options respectively.
-}
drawDiagramsWith :: Options -> Pandoc -> Compiler Pandoc
drawDiagramsWith opts = walkM visitor
  where
    visitor :: Block -> Compiler Block
    visitor (CodeBlock (ident, classes, attrs) code)
      | "diagram" `elem` classes = do
          destDir <- destinationDirectory . compilerConfig <$> compilerAsk
          unsafeCompiler $
            transformBlock opts destDir $
              CodeBlock (ident, filter (/= "diagram") classes, attrs) code
    visitor b = pure b


-- | Call 'drawDiagramsWith' with the 'defaultOptions'.
drawDiagrams :: Pandoc -> Compiler Pandoc
drawDiagrams = drawDiagramsWith (def :: Options)